@openpresentation/opf-pptx 0.20.4 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
2
+ import {addNativeBody, nativeBodyDefaults, nativeTitleAutofit, planNativeBody} from './native-body.js';
2
3
  import {autoNumScheme, deriveListNumbering, withDisplayedNumbers} from './numbered-list.js';
3
4
  import {isFaceStyleSuffix} from './font-weights.js';
4
5
  import {importTableFrames} from './table-import.js';
@@ -29,15 +30,15 @@ import {restoreRunColors} from './run-colors.js';
29
30
  import {joinWrappedText} from './content-topology.js';
30
31
  import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, recordListRuns, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListRecord, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
32
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
- import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
+ import {attachFurnitureFields, furniturePartFields, isBareSlideNumber, lineFields, nativeFieldType} from './furniture-fields.js';
33
34
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
35
  import {liftMasterFurniture} from './master-furniture.js';
35
36
  import {gallery} from '@openpresentation/gallery';
36
37
  import {applyTemplateLayouts, deckTemplates, foreignLayoutReference, placeholderRegion, readLayoutTag, slideTemplate, writeTemplateLayouts} from './template-layouts.js';
37
38
  import {importImageOrientation} from './image-import.js';
38
39
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
39
- import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
40
- import {dedupeMedia} from './media-dedupe.js';
40
+ import {recordableImageSource, placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, samePictureAspect, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
41
+ import {dedupeMedia, mediaKey} from './media-dedupe.js';
41
42
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
42
43
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
43
44
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
@@ -48,11 +49,12 @@ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFil
48
49
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
49
50
  import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
50
51
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
52
+ import { chartPaletteSchemes, applyChartSchemeColors } from './chart-scheme-colors.js';
51
53
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
52
54
  import {
53
- catalogRecords, chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
55
+ catalogRecords, chartPaletteForFill, chartSeriesPalette, chartTextSize as coreChartTextSize, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
54
56
  ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
55
- resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
57
+ resolveChartColors, resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
56
58
  } from "@openpresentation/opf/composition";
57
59
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
58
60
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
@@ -199,21 +201,6 @@ const DIMENSION_PRESETS = Object.freeze({
199
201
  a4: Object.freeze({ widthInches: 11.69, heightInches: 8.27 })
200
202
  });
201
203
 
202
- const CHART_COLORS = [
203
- "2874A6",
204
- "1B4F72",
205
- "5499C7",
206
- "7BDBB2",
207
- "3AC67A",
208
- "24A89E",
209
- "F59E0B",
210
- "EF4444",
211
- "8B5CF6",
212
- "14B8A6",
213
- "0F172A",
214
- "64748B"
215
- ];
216
-
217
204
  // The `catalogs` option is core's registered catalogs (`Catalog[]`). A value that is no array is core's
218
205
  // OPFCatalogsOptionError (code `invalid-catalogs`) up front; core checks each entry where it resolves a reference.
219
206
  function checkCatalogs(entry, catalogs) {
@@ -221,12 +208,21 @@ function checkCatalogs(entry, catalogs) {
221
208
  }
222
209
 
223
210
  export async function toPptx(input, options = {}) {
211
+ if (options.bodyText !== undefined && !['measured', 'native'].includes(options.bodyText)) {
212
+ throw new OPFPptxError('invalid-body-text-mode', 'bodyText must be measured or native.', {path: 'options.bodyText'});
213
+ }
224
214
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
225
215
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
226
216
  }
217
+ if (options.slideNumberFields !== undefined && !['static', 'live'].includes(options.slideNumberFields)) {
218
+ throw new OPFPptxError('invalid-slide-number-option', "slideNumberFields must be 'static' or 'live'.", {path: 'options.slideNumberFields'});
219
+ }
227
220
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
228
221
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
229
222
  }
223
+ if (options.footerText !== undefined && !['native', 'shape'].includes(options.footerText)) {
224
+ throw new OPFPptxError('invalid-footer-text-option', "footerText must be 'native' or 'shape'.", {path: 'options.footerText'});
225
+ }
230
226
  // OPF 0.15 (FA-23): the host's registered catalogs (core `Catalog[]`, matched by `source`, never fetched) reach every core
231
227
  // call that resolves a reference. Strict export (`strictReferences`, default `strictAssets`) fails on a reference that
232
228
  // resolves nowhere instead of composing automatically or drawing with the engine default.
@@ -272,6 +268,8 @@ export async function toPptx(input, options = {}) {
272
268
  context.provenanceMode = options.provenance ?? "full";
273
269
  context.headingTags = new Map();
274
270
  context.plainTextTags = new Map();
271
+ context.nativeBodies = new Map();
272
+ context.nativeTitles = new Set();
275
273
  context.timelineTags = new Map();
276
274
  context.quoteTags = new Map();
277
275
  context.quotePhotos = new Map();
@@ -281,10 +279,12 @@ export async function toPptx(input, options = {}) {
281
279
  context.furnitureManifests = new Map();
282
280
  context.nativeFurniture = new Map();
283
281
  context.nativeFurnitureBands = new Map();
282
+ context.footerText = options.footerText ?? 'native';
284
283
  // RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
285
284
  context.furnitureParts = [];
286
285
  // RR-81: the 0.19 template each slide uses (its native slide layout) and the objects bound to a region placeholder.
287
286
  context.templateSlides = [];
287
+ context.staticSlideNumbers = options.slideNumberFields !== 'live';
288
288
  context.automaticSlides = [];
289
289
  context.placeholderBindings = new Map();
290
290
  context.hostDate = options.date;
@@ -463,7 +463,7 @@ export async function fromPptx(input, options = {}) {
463
463
  return match ? shapeBounds(match['p:spPr']?.['a:xfrm']) ?? undefined : undefined;
464
464
  } : undefined,
465
465
  // RR-11: native footer placeholders resolve their position through the layout and master.
466
- path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
466
+ path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), slideHeight: Number(presentationRoot['p:sldSz']?.cy), defaultTextStyle: presentationRoot['p:defaultTextStyle'], relationshipsOf: cachedRelationships, readPart: cachedPart,
467
467
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
468
468
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
469
469
  diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`})),
@@ -801,6 +801,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
801
801
  // The recorded crop, fit and focus are re-derived from the restored background.
802
802
  if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`});
803
803
  }),
804
+ mediaKeyOf: picture => { const bytes = entries[relationships.get(picture?.['p:blipFill']?.['a:blip']?.['r:embed'])?.path]; return bytes ? mediaKey(bytes) : undefined; },
804
805
  valid: (kind, value) => isValidFormat({slides: [kind === 'background' ? {design: {background: value}} : {blocks: [value]}]}),
805
806
  report: diagnostic => options.onDiagnostic?.(diagnostic)});
806
807
  const imageBackground = images.background ?? (background?.type === 'image' && images.backgroundOverlay ? {...background, overlay: images.backgroundOverlay} : background);
@@ -811,6 +812,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
811
812
  if (imageBackground) slide.design = {background: imageBackground};
812
813
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
813
814
  nativeContext.imageBlocks = images.blocks;
815
+ nativeContext.imageSources = images.sources;
814
816
  nativeContext.imagePictures = images.consumed;
815
817
  // An overlay of a background that was not recovered stays an ordinary shape.
816
818
  nativeContext.imageShapes = images.consumedShapes;
@@ -867,8 +869,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
867
869
  for (const item of items) if (item.readNativeBody) {
868
870
  const current = item.readNativeBody();
869
871
  if (current) {
870
- item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
872
+ item.paragraphs = withDisplayedNumbers(current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph})));
871
873
  item.text = current.map(paragraph => paragraph.text).join('\n');
874
+ if (item.paragraphs.some(paragraph => paragraph.bullet) && item.paragraphs.some(paragraph => !paragraph.bullet))
875
+ options.onDiagnostic?.({code: 'mixed-body-paragraphs', path: `slides.${slideIndex}`, message: 'A native body mixes bulleted and plain paragraphs; current text is retained as list entries because OPF has no mixed-paragraph body model.'});
872
876
  }
873
877
  }
874
878
  const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
@@ -1040,7 +1044,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
1040
1044
  const treatment = nativeContext.imageBlocks?.get(index);
1041
1045
  const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
1042
1046
  const item = importPicture(entries, picture, slidePath, relationships, report);
1043
- if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
1047
+ if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment), ...(nativeContext.imageSources?.has(index) ? {image: {...item.payload.image, src: nativeContext.imageSources.get(index)}} : {})};
1044
1048
  else if (item?.nativeStretch) { item.payload.fit='stretch'; item.inferredStretch=true; }
1045
1049
  else if (item?.nativeFitUnsupported) report({code:'unsupported-image-fit',message:'Native picture tiling or an offset stretch fill is not represented by the imported image block; review the reflowed image.'});
1046
1050
  if (item) items.push({...item, sources: [`pic:${index}`]});
@@ -1224,13 +1228,12 @@ function nativePictureStretch(picture,bytes,bounds,svg) {
1224
1228
  if(!bounds||!(bounds.w>0&&bounds.h>0)||!metadata||!(metadata.width>0&&metadata.height>0)||metadata.orientation>1) return false;
1225
1229
  if(stretch===undefined||fill?.['a:tile']!==undefined||Number(transform?.rot??0)!==0) return false;
1226
1230
  if([crop,rect].some(value=>value&&['l','r','t','b'].some(key=>Number(value[key]??0)!==0)))return false;
1227
- // Compare dimensions in native EMUs, without a new geometry tolerance.
1231
+ // Compare the current raster and current native frame, including only the
1232
+ // exporter's representable rounding cells (not a visual geometry tolerance).
1228
1233
  const ext=transform?.['a:ext'];
1229
1234
  const cx=Number(ext?.cx),cy=Number(ext?.cy);
1230
1235
  if(!Number.isFinite(cx)||!Number.isFinite(cy))return false;
1231
- // Equal-aspect dimensions can round by one EMU when serialized. A dimension
1232
- // reproduced by rounding the intrinsic ratio is still the same aspect.
1233
- return Math.round(cx*metadata.height/metadata.width)!==cy&&Math.round(cy*metadata.width/metadata.height)!==cx;
1236
+ return !samePictureAspect(cx,cy,metadata.width,metadata.height);
1234
1237
  }
1235
1238
 
1236
1239
  // Provenance may rebuild flat blocks into groups or scalar fields. Remove only
@@ -1321,7 +1324,7 @@ function takeTitleItem(items, dimensions, inferHeadings) {
1321
1324
 
1322
1325
  const titleLimit = dimensions.heightInches * 0.28;
1323
1326
  const candidateIndex = items.findIndex((item) => {
1324
- if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1327
+ if (item.heading || item.placeholder === 'body' || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1325
1328
  const y = item.bounds?.y ?? 0;
1326
1329
  return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
1327
1330
  });
@@ -1337,7 +1340,7 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
1337
1340
  const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
1338
1341
  const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
1339
1342
  const candidateIndex = items.findIndex((item) => {
1340
- if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1343
+ if (item.heading || item.placeholder === 'body' || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1341
1344
  const y = item.bounds?.y ?? 0;
1342
1345
  const h = item.bounds?.h ?? 0;
1343
1346
  return y >= titleBottom - 0.05
@@ -1521,6 +1524,15 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1521
1524
  if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath, {codes: valueCodes, xCode, report}));
1522
1525
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1523
1526
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1527
+ // RR-86: explicit current line paint has no per-series OPF field. Report the native locator without
1528
+ // inferring semantic highlight, rewriting the theme or claiming that these colors differ visually.
1529
+ for (const [index, entry] of series.entries()) {
1530
+ const line = comboSeries ? comboSeries[index].role === 'line' : asArray(plotArea['c:lineChart']).includes(chartNode.node);
1531
+ const solid = line && entry?.['c:spPr']?.['a:ln']?.['a:solidFill'];
1532
+ if (solid && ['a:srgbClr', 'a:schemeClr', 'a:sysClr', 'a:prstClr', 'a:scrgbClr', 'a:hslClr'].some(color => Object.hasOwn(solid, color))) {
1533
+ report?.({option: `series.${index}.line.color`, message: `Line series '${names[index]}' has explicit native solid paint that is not retained as per-series formatting in OPF; imported chart colors follow its design and semantic highlight.`});
1534
+ }
1535
+ }
1524
1536
  const values = series.map((entry, index) => {
1525
1537
  const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
1526
1538
  return cachedValues(entry?.[role], cachePath(index, role), budget, strictCacheValue(budget));
@@ -2128,9 +2140,12 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2128
2140
  for (const item of geometry.items) if (item.listColumns?.length > 1 && item.text?.listEntries) item.text = {...item.text, listEntries: item.listColumns.flatMap(column => column.text?.listEntries ?? [])};
2129
2141
  // RR-81: a slide on a 0.19 template uses that template's native slide layout (src/template-layouts.js), whose placeholders
2130
2142
  // core composes with this slide's options (canvas, the deck's header and footer, direction) and its placed-image bands.
2131
- const template = slideTemplate(slideContext.core);
2143
+ let template = slideTemplate(slideContext.core);
2144
+ const automatic = opfSlide.layout === undefined || opfSlide.layout === 'auto';
2145
+ const nativeBody = options.bodyText === 'native' ? planNativeBody(opfSlide, geometry, {automatic, report: options.onDiagnostic}) : undefined;
2146
+ if (template && nativeBody) template = {...template, key: `${template.key}\u0000native-body:${nativeBody.region}`, nativeBodyRegion: nativeBody.region};
2132
2147
  if (template) context.templateSlides[slideIndex] = {template, areaOptions: {...composeOptions, placements: placedImageBands(opfSlide)}};
2133
- else if (opfSlide.layout === undefined || opfSlide.layout === 'auto') context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title')};
2148
+ else if (automatic) context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title'), ...(nativeBody ? {nativeBody} : {})};
2134
2149
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
2135
2150
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
2136
2151
  slideContext.zoneBand = zoneBands(composeOptions);
@@ -2151,7 +2166,8 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2151
2166
 
2152
2167
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
2153
2168
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
2154
- recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
2169
+ const nativePlans = nativeBody ? [nativeBody, ...nativeBody.others] : [];
2170
+ recordContentTopology(context.documentProvenance, opfSlide, slideIndex, nativeBody ? geometry.items.map(item => { const plan = nativePlans.find(plan => plan.item === item); return plan ? {...item, box: plan.box, text: undefined} : item; }) : geometry.items);
2155
2171
  // Paint order, as in the preview (FA-22 contract): canvas colour, the background picture and its overlay, design.watermark,
2156
2172
  // the logo, then the items in core's order: the headings, the placed image blocks at their bands, then the flowed body.
2157
2173
  if (geometry.backgroundImage) await addBackgroundImage(slide, presentation, geometry.backgroundImage, slideIndex, slideContext, context, options);
@@ -2168,7 +2184,10 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2168
2184
  // object or one drawn on a card stays a free shape: Change Layout would move it away from its caption or card.
2169
2185
  const boundRegion = template && item.region !== undefined && ['image', 'chart', 'table'].includes(item.field) && !item.caption && !item.frameBox
2170
2186
  ? geometry.regions?.find(region => region.name === item.region && region.flow === 'none' && region.content?.length === 1 && !region.overflow?.length) : undefined;
2171
- if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && item.text?.lines?.length === 1 && !item.text.overflow && !item.text.richLines)
2187
+ // RR-88: under bodyText 'native' a plain-string title that wraps is one native text box (native wrap, no autofit) at its measured
2188
+ // block, so it can bind to the Title placeholder like a one-line title (Keynote reports `title showing` only for a bound title).
2189
+ const nativeTitle = options.bodyText === 'native' && (template || context.automaticSlides[slideIndex]) && isNativeTitle(item);
2190
+ if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && (item.text?.lines?.length === 1 || nativeTitle) && !item.text.overflow && !item.text.richLines)
2172
2191
  context.placeholderBindings.set(`OPF heading ${item.path} line 0`, {slide: slideIndex, region: 'Title', object: 'title'});
2173
2192
  const drawnBefore = boundRegion && {images: context.images.size, placeholders: context.imagePlaceholders.size, charts: context.chartHeadings.size, tables: context.tableHeaders.size};
2174
2193
  // Card text sits on a literal card fill, not the slide background: keep it literal.
@@ -2183,7 +2202,15 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2183
2202
  rectRadius:8*Math.min(widthInches,heightInches)/720,
2184
2203
  fill:paint(surface),line:{...paint(`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
2185
2204
  }
2186
- if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
2205
+ if (nativeTitle) {
2206
+ addNativeTitle(slide, item, itemContext, options, context);
2207
+ } else if (nativePlans.some(plan => plan.item === item)) {
2208
+ // RR-88: the first native body is the Body placeholder; every further one is an ordinary native text box at its region.
2209
+ const plan = nativePlans.find(plan => plan.item === item);
2210
+ const body = addNativeBody(slide, plan, itemContext, options, {textBoxOptions, nativeFontOptions, exportColor, nativeColor, linkColor, langOptions, solidColorXml});
2211
+ context.nativeBodies.set(body.objectName, body.defaults);
2212
+ if (plan === nativeBody) context.placeholderBindings.set(body.objectName, {slide: slideIndex, region: nativeBody.region, object: 'body'});
2213
+ } else if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
2187
2214
  addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
2188
2215
  } else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
2189
2216
  // Estimated-font headings use one native text box, which still needs a
@@ -2221,7 +2248,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2221
2248
  } else {
2222
2249
  // RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
2223
2250
  const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
2224
- await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
2251
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout, item.chart);
2225
2252
  if (item.caption) {
2226
2253
  const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
2227
2254
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
@@ -2289,7 +2316,7 @@ function fieldToType(field) {
2289
2316
  return field === "items" || field === "bullets" ? "list" : field;
2290
2317
  }
2291
2318
 
2292
- async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
2319
+ async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout,chartLayout) {
2293
2320
  const kind = inferPayloadKind(payload);
2294
2321
  switch (kind) {
2295
2322
  case "text":
@@ -2305,7 +2332,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
2305
2332
  addMediaPayload(slide, presentation, payload.video, region, path, context, options);
2306
2333
  break;
2307
2334
  case "chart":
2308
- addChartPayload(slide, payload.chart, region, context, options, path, presentation);
2335
+ addChartPayload(slide, payload.chart, region, context, options, path, presentation, chartLayout);
2309
2336
  break;
2310
2337
  case "table":
2311
2338
  addTablePayload(slide, payload.table, region, context, options, path, presentation);
@@ -2472,7 +2499,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
2472
2499
  // One native image picture at `box` (inches), fitted at packaging (src/image-provenance.js placeImages). `treatment` is
2473
2500
  // what OPF_IMAGE_V1 stores (the authored block fields); without it the picture is untagged. Returns the picture's object
2474
2501
  // name, or undefined when the source could not be embedded (the caller draws the unavailable-image panel).
2475
- async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
2502
+ async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName, src} = {}) {
2476
2503
  const outcome = { box, cover: fit === 'cover' };
2477
2504
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
2478
2505
  if (!resolved) {
@@ -2490,7 +2517,8 @@ async function addImagePicture(slide, presentation, asset, box, path, context, o
2490
2517
  else context.svgPictures.set(name, outcome.svg);
2491
2518
  }
2492
2519
  const text = alt !== undefined ? alt : assetAlt(asset, presentation);
2493
- context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}) });
2520
+ // RR-94: the authored relative path or URL is recorded with the picture's media key; an SVG imports from its own part, so it is not.
2521
+ context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}), ...(src !== undefined && !outcome.svg ? {src} : {}) });
2494
2522
  context.pictureText.set(name, pictureText(text, asset, presentation));
2495
2523
  slide.addImage({ ...resolved, objectName: name, x: box.x, y: box.y, w: box.w, h: box.h, altText: text });
2496
2524
  return name;
@@ -2538,11 +2566,13 @@ async function addImageItem(slide, presentation, item, slideIndex, context, opti
2538
2566
  const treatment = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay' && block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2539
2567
  const effects = imageEffects(image, context);
2540
2568
  const treated = Boolean(effects.border || effects.opacity !== null || effects.recolor || image.overlay || (effects.shape && effects.shape.preset !== 'rect'));
2541
- const tagged = Object.keys(treatment).length > 0 || block.overlay !== undefined;
2569
+ // RR-94: every image block is tagged (an empty treatment when it has none), so an unchanged picture re-imports without a crop
2570
+ // diagnostic, and one authored from a relative path or URL records that source to give it back.
2571
+ const source = recordableImageSource(item.value);
2542
2572
  // A plain contained picture in the flow is framed by the picture itself (0.14 content images); a treated or placed one
2543
2573
  // keeps core's frame (0.14 slide images), so its mask, line and overlay apply to that frame.
2544
2574
  const name = await addImagePicture(slide, presentation, item.value, box, item.path, { ...context, slidePath: `slides.${slideIndex}` }, options, {
2545
- fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2575
+ fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment, src: source
2546
2576
  });
2547
2577
  if (!name) {
2548
2578
  const panel = addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
@@ -2665,17 +2695,17 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
2665
2695
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
2666
2696
  }
2667
2697
 
2668
- // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
2669
- // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
2670
- // the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
2671
- // RR-16: core composes every font size on PowerPoint's 0.01 pt grid (1/75 px), so the preview draws the grid size, not the raw
2672
- // product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
2673
- // Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
2674
- function chartTextSize(context) {
2698
+ // FF-62, RR-90: chart text is one size, core's `chartTextSize` for the chart's box: 20 px at the 1280 x 720 reference (15 pt on a 13.33 x 7.5 in
2699
+ // slide), shrunk only when the box is small (at most 1/18 of its height and 1/30 of its width, whole pixels) and never below
2700
+ // `composition.minFontSize` (default 16), scaled by the slide's shorter side over 720 px. The preview (opf-render charts.js) and core's value-axis
2701
+ // density call the same function with the same inputs, so the axis core composed matches the text drawn. The export writes the size in hundredths
2702
+ // of a point (DrawingML `sz`) on core's 0.01 pt grid, as core composes every font size (RR-16): the request rounds down, never below the
2703
+ // grid-rounded floor (core `gridFontSize`, `snapFontSizeUp(floor)` against `snapFontSizeDown(request)`).
2704
+ function chartTextHundredths(context, region) {
2675
2705
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
2676
2706
  const floor = context.composition?.minFontSize ?? 16;
2677
- const hundredths = Math.max(14, floor) * scale * 75;
2678
- return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
2707
+ const px = coreChartTextSize({width: region.w * 96, height: region.h * 96, scale, minFontSize: floor});
2708
+ return Math.max(Math.ceil(floor * scale * 75 - 1e-6), Math.floor(px * scale * 75 + 1e-6));
2679
2709
  }
2680
2710
 
2681
2711
  // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
@@ -2685,7 +2715,7 @@ function applyChartTextSize(xml, size) {
2685
2715
  `<a:defRPr${/\bsz="[^"]*"/.test(attributes) ? attributes.replace(/\bsz="[^"]*"/, `sz="${size}"`) : `${attributes} sz="${size}"`}${close}>`));
2686
2716
  }
2687
2717
 
2688
- function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation) {
2718
+ function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation, chartLayout) {
2689
2719
  const chartData = toPptxChartData(chart, context.chartexMode, presentation, path);
2690
2720
  if (!chartData.series) {
2691
2721
  // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
@@ -2709,7 +2739,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2709
2739
  const fill = {color:normalizeHex(panelFill),transparency};
2710
2740
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
2711
2741
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
2712
- const textSize = chartTextSize(context);
2742
+ const textSize = chartTextHundredths(context, region);
2713
2743
  // RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
2714
2744
  // The value axis shows the first plotted series' format (General when it has none), as the preview does.
2715
2745
  // A combo chart's secondary value axis shows its first series' format (FA-15).
@@ -2725,8 +2755,15 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2725
2755
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2726
2756
  // FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
2727
2757
  if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
2728
- const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2729
- const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
2758
+ // RR-89: the series colours are core's chartSeriesPalette (accent1 to accent6 of the deck scheme, then their tints, with the chart's own
2759
+ // `colors` over them), adjusted for the chart panel like the preview does. A colour that is still an unadjusted theme accent (or a
2760
+ // `chart.colors` scheme slot or role) is written as a schemeClr, so re-theming in PowerPoint recolours the series.
2761
+ const colorContext = {roles: context.roles, variables: context.variables};
2762
+ const preferredPalette = chartSeriesPalette(context.colorScheme, chart.colors, colorContext);
2763
+ const adjustedPalette = chartPaletteForFill(panelFill, preferredPalette);
2764
+ const palette = adjustedPalette.map(color=>normalizeHex(color));
2765
+ const paletteSchemes = chartPaletteSchemes(chart.colors, preferredPalette, adjustedPalette, (reference, hex) => schemeColorValue(reference, `#${hex}`, context));
2766
+ Object.assign(context.chartHeadings.get(objectName), {palette, paletteSchemes, circular});
2730
2767
  // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
2731
2768
  // contrast with those colours (src/chart-highlight.js).
2732
2769
  const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
@@ -2779,6 +2816,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2779
2816
  ...(chartData.series.every(entry => entry.values.every(value => value == null || value >= 0)) ? {valAxisMinVal: 0} : {}),
2780
2817
  ...(chartData.series.some(entry => entry.values.some(value => value < 0)) && chartData.series.every(entry => entry.values.every(value => value == null || value <= 0)) ? {valAxisMaxVal: 0} : {})
2781
2818
  } : {}),
2819
+ // RR-92: the value axis core composed (column, bar, line and area charts), as the preview draws it: PowerPoint and Keynote
2820
+ // otherwise choose their own bounds and major unit. A 100% stacked chart's axis is a fraction.
2821
+ ...(chartLayout?.valueAxis ? {valAxisMinVal: chartLayout.valueAxis.min, valAxisMaxVal: chartLayout.valueAxis.max, valAxisMajorUnit: chartLayout.valueAxis.majorUnit} : {}),
2782
2822
  barDir: chartData.barDir,
2783
2823
  barGrouping: chartData.barGrouping,
2784
2824
  // Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
@@ -2986,6 +3026,32 @@ function assertXmlText(text, path) {
2986
3026
 
2987
3027
  // Match the published renderer's payload geometry and shared core text fitting.
2988
3028
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
3029
+ // RR-88: a plain-string title that wraps over several lines, with no hard break, overflow, rich runs or right-to-left line.
3030
+ function isNativeTitle(item) {
3031
+ return item.field === 'title' && typeof item.value === 'string' && item.value.length > 0 && !/[\r\n\u2028\u2029]/.test(item.value) && item.text?.lines?.length > 1
3032
+ && !item.text.overflow && !item.text.richLines && !item.text.directions?.includes('rtl') && !item.text.sourceLines?.some(line => line.boundary === 'hard' || line.segments?.some(segment => segment.kind === 'tab'));
3033
+ }
3034
+
3035
+ // RR-88: the wrapped title as one native text box: the whole string, native wrap, no autofit, at the block its measured lines fill
3036
+ // (top of the first line to the bottom of the last), with the measured line pitch as exact spacing. Tagged as one heading line,
3037
+ // so import reads the current words as the title. The caller binds it to the Title placeholder.
3038
+ function addNativeTitle(slide, item, context, options, deckContext) {
3039
+ const fit = item.text, box = item.box, lines = fit.placement?.lines;
3040
+ const tops = fit.lines.map((_, index) => lines?.[index] ? lines[index].baseline - fit.fontSize : box.y + index * fit.lineHeight);
3041
+ const bottoms = fit.lines.map((_, index) => lines?.[index] ? lines[index].baseline - fit.fontSize + lines[index].height : box.y + (index + 1) * fit.lineHeight);
3042
+ const top = Math.min(...tops), height = Math.max(...bottoms) - top;
3043
+ const objectName = `OPF heading ${item.path} line 0`;
3044
+ deckContext.headingTags.set(objectName, {v: 1, group: item.path, field: 'title', line: 0, count: 1, boundary: 'end', separator: ''});
3045
+ deckContext.nativeTitles.add(objectName);
3046
+ const style = item.textStyle ?? resolveTextStyle({fontFamily: context.fonts.body, fontWeight: 400, path: item.path}, options.textMeasurement);
3047
+ slide.addText(item.value, {
3048
+ ...textBoxOptions({x: box.x / 96, y: top / 96, w: box.width / 96, h: height / 96}, context, fit.fontSize * .75),
3049
+ ...nativeFontOptions(style),
3050
+ color: pptxColor(context.textColor), align: physicalAlignment(fit.placement?.alignment ?? item.alignment ?? 'left', fit.directions?.[0]),
3051
+ objectName, fit: 'none', wrap: true, lineSpacing: fit.lineHeight * .75, lineSpacingMultiple: undefined
3052
+ });
3053
+ }
3054
+
2989
3055
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
2990
3056
  assertXmlText(text, config.path);
2991
3057
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
@@ -3066,7 +3132,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
3066
3132
  }
3067
3133
  const staticDates = new Map();
3068
3134
  // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
3069
- const natives = nativeFurnitureParts(layout);
3135
+ const natives = nativeFurnitureParts(layout, {staticNumbers: context.staticSlideNumbers});
3070
3136
  for(const [index,part]of layout.parts.entries()){
3071
3137
  const entry = {index, kind: part.kind, zone: part.zone, field: part.field, type: part.type, path: part.path, native: natives.has(index), names: []};
3072
3138
  drawn.push(entry);
@@ -3087,6 +3153,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
3087
3153
  // en-US field type is written as its laid-out text.
3088
3154
  const fields=[];
3089
3155
  for(const field of furniturePartFields(part)){
3156
+ // RR-91 (slideNumberFields, default 'static'): a number with words around it is fixed text; Keynote drops the words next to a live number.
3157
+ if(field.type==='slideNumber'&&context.staticSlideNumbers&&!isBareSlideNumber(part))continue;
3090
3158
  const nativeType=nativeFieldType(field);
3091
3159
  if(nativeType)fields.push({...field,nativeType});
3092
3160
  else options.onDiagnostic?.({code:'furniture-date-fixed',path:part.path,message:`dateFormat '${field.format}' has no PowerPoint en-US date field; the current date is exported as fixed text that PowerPoint will not update.`});
@@ -4069,7 +4137,7 @@ async function normalizePptxZip(raw, context) {
4069
4137
  attachFurnitureFields(entries,context.furnitureFields);
4070
4138
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
4071
4139
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
4072
- context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
4140
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands,context.footerText);
4073
4141
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
4074
4142
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
4075
4143
  // opf-pptx#221: the image block of an unavailable-image placeholder, on its panel (full provenance).
@@ -4089,6 +4157,10 @@ async function normalizePptxZip(raw, context) {
4089
4157
  if(highlight){
4090
4158
  try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
4091
4159
  catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
4160
+ }else{
4161
+ // RR-89: series that keep a theme accent (or a chart.colors scheme slot) are written as theme colours.
4162
+ const {palette,paletteSchemes,circular}=context.chartHeadings.get(name);
4163
+ if(paletteSchemes)entries[chartPart]=encodeText(applyChartSchemeColors(decodeText(entries[chartPart]),palette,paletteSchemes,{circular}));
4092
4164
  }
4093
4165
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
4094
4166
  // Normalize only our generated chart text styles; point/series fills stay intact.
@@ -4166,6 +4238,8 @@ async function normalizePptxZip(raw, context) {
4166
4238
  try {
4167
4239
  placeImages(entries, context.images, context.imageOverlays, {
4168
4240
  metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
4241
+ // The key of the bytes the package finally holds: the picture's orientation is normalized when the parts are written.
4242
+ mediaKeyFor: (part, id) => { const target = parseRelationships(entries, part).get(id)?.path, bytes = entries[target]; return bytes ? mediaKey(imageMetadata.get(target) ? normalizeImageOrientation(bytes, imageMetadata.get(target)) : bytes) : undefined; },
4169
4243
  fail: path => { throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path }); }
4170
4244
  });
4171
4245
  } catch (error) {
@@ -4490,6 +4564,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4490
4564
  const content=body.replace(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/g,node=>{properties ||= node;return '';});
4491
4565
  return `<a:p>${properties}${content}</a:p>`;
4492
4566
  });
4567
+ xml = nativeTitleAutofit(nativeBodyDefaults(xml, context.nativeBodies), context.nativeTitles);
4493
4568
  }
4494
4569
  // theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
4495
4570
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
@@ -1,6 +1,6 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {isValidFormat} from './format-check.js';
3
- import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
+ import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
4
4
 
5
5
  const TAG='OPF_METRIC_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
6
6
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
@@ -14,7 +14,7 @@ export function metricManifest(value,layout,group) {
14
14
  return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
15
15
  lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
16
16
  }
17
- export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
17
+ export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions,signed:true});}
18
18
 
19
19
  function validateManifest(manifest) {
20
20
  if (!isValidFormat({slides:[{metric:manifest.value}]})) throw new Error('Invalid metric source.');
@@ -50,7 +50,7 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
50
50
  const data=decodeTextTag(tag.val);
51
51
  if (data.v!==1||typeof data.group!=='string'||!/^\d+$/.test(data.group)) throw new Error('Invalid metric identity.');
52
52
  const group=groups.get(data.group)??[];
53
- group.push({data,shape,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
53
+ group.push({data,shape,index,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
54
54
  } catch {report({code:'invalid-metric-provenance',message:'Invalid metric tags; visible native shapes were retained.'});}
55
55
  }
56
56
  }
@@ -91,7 +91,7 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
91
91
  for (const item of group) consumed.add(item.shape);
92
92
  items.push({shape:anchor.shape,payload:{type:'metric',metric}});
93
93
  if (typeChanges.length) report({code:'metric-value-type-changed',message:`Edited numeric ${typeChanges.join(', ')} retained as literal text to avoid discarding spelling or precision.`});
94
- report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
94
+ if(!groupUnchanged(group,paragraphs))report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
95
95
  } catch (error) {report({code:'invalid-metric-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});}
96
96
  return {consumed,items};
97
97
  }