@openpresentation/opf-pptx 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ const bytes = await toPptx({
63
63
  await fs.promises.writeFile("quarterly-review.pptx", bytes);
64
64
  ```
65
65
 
66
- `toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Every picture path (image blocks and region images, quote photos, slide and deck background pictures, watermarks, cover logos, header and footer images and logos, picture bullets) reports an unresolved source with exactly one `unresolved-asset` diagnostic at its OPF path and draws the preview's fallback (the placeholder, the background colour, no watermark, or the character bullets), so strict CLI export (`--fail-on warning`) gates it. A local file path that cannot be read is unresolved like any other source (`reason: 'file-unreadable'`), instead of failing the export. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
66
+ `toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Every picture path (image blocks and region images, quote photos, slide and deck background pictures, watermarks, cover logos, header and footer images and logos, picture bullets) reports an unresolved source with exactly one `unresolved-asset` diagnostic at its OPF path and draws the preview's fallback (the placeholder, the background colour, no watermark, or the character bullets), so strict CLI export (`--fail-on warning`) gates it. A local file path that cannot be read is unresolved like any other source (`reason: 'file-unreadable'`), instead of failing the export. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes. The "Image unavailable" placeholder (a dashed panel `OPF image placeholder N` with its label lines, or a cross when no label fits) is one native group `OPF image placeholder N group` (opf-pptx#221) that carries the accessible name (`Image unavailable: <alt>`) as its alt text, so PowerPoint moves or deletes it as one object while each member stays an editable shape; a logo and a header or footer image keep their ungrouped panel (generated furniture is never grouped). In `full` provenance the panel of an image block carries an `OPF_IMAGE_PLACEHOLDER_V1` tag (the slide, the block path, the authored image value and the block's own fields), and `fromPptx` turns it back into that image block; without the tag (`references-only`, `provenance: false`) or with a damaged one (`invalid-image-provenance`) the shapes import as ordinary content.
67
67
 
68
68
  Embedded picture bytes are a PNG, JPEG, GIF, WebP or SVG. Bytes that are no readable image (or an SVG that cannot be exported: malformed XML, no `xmlns`, no width and height and no viewBox, or no rasterizer, see below) export what the preview shows, like an unresolved asset: the "Image unavailable" placeholder (nothing for a watermark, the background colour for a background image) and one `unresolved-asset` diagnostic with a `reason` (`unsupported-format`, `svg-malformed`, `svg-no-size`, `svg-too-large`, `svg-unsafe`, `svg-unreadable`, `svg-rasterizer-unavailable`, `svg-render-failed`); `strictAssets` throws `unsupported-image-dimensions` for an unreadable raster, `invalid-svg-image` for an unusable SVG, `svg-rasterizer-unavailable` or `svg-render-failed`.
69
69
 
@@ -100,7 +100,7 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
100
100
 
101
101
  - `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
102
102
  - Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
103
- - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more.
103
+ - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more. Every classic chart part follows the `dml-chart.xsd` child sequences (opf-pptx#175): each series is written in its `CT_*Ser` order (a line series has `c:marker` before `c:dLbls`), a radar series carries no `c:invertIfNegative`, and the scatter X `c:valAx` carries no category-axis elements.
104
104
  - A timeline event's `status` (`done`, `current`, `planned`) is drawn from the deck's colors with core's shared shapes (the same as the preview): a filled `ellipse` marker for `done` and for no status, a `current` marker inside a ring (a second `ellipse`, tagged with its marker) with a bold label, and a `planned` marker that is an `ellipse` with the background fill and an accent outline, with muted text kept at 4.5:1 or more. Import restores `status` from the marker tags, not from the shapes' look, and drops the timeline provenance (keeping plain text) when a current event's ring is missing or duplicated.
105
105
  - A chart whose data is one column of values (a histogram or dot plot) has no category column. A histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip). Cells parse as in every chart (core `chartNumber`: numbers and plain decimal strings; "12%", "$5" and "1,234" are not numbers) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
106
106
  - Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path. A picture's native description is the authored `alt` (never a stand-in such as `preencoded.png` or the file path), and a distinct asset `title` is the picture's native `title`; `fromPptx` reads both back.
@@ -143,7 +143,7 @@ The first importer is mechanical and schema-compatible:
143
143
  - The first slide master's theme `clrScheme` maps to `design.colorScheme`: the bare id of a colour scheme in the host default catalog (the first of `fromPptx`'s `catalogs`) on an exact twelve-slot match, otherwise inline slots. A theme named like a theme of that catalog maps to `design.theme` when its colors or heading/body fonts corroborate it. With no catalog registered the colour scheme is inline and no theme id is recovered (see [Theme color scheme](#theme-color-scheme)).
144
144
  - Rich headline text (FA-10): a `TextRun[]` `title`, `subtitle` or `tag` exports as one tagged native text box per laid-out line with its runs (color, weight, italic, links, super/subscript, citation markers), and a `TextRun[]` `quote.text` the same way under the quote tags. Import returns `TextRun[]` where a heading's native runs are formatted differently from one another and a plain string where they are uniform: a tagged export records the heading's own weight, color, size and family (`base`), so only authored run formatting comes back, and a native title placeholder is read like body text, dropping what every run shares. A weight that the export writes as bold on every run of a title (and on a quote body) is the default, so only a `bold: false` word in a title is kept; `bold: true` in a quote body is indistinguishable from the body weight and is not restored. Marker runs become `cite`/`footnote` again, and heading and quote runs take part in the run-color reference slots.
145
145
  - Native title/subtitle placeholders retain their roles. On slides without complete OPF heading tags, recognizable text-box positions and sizes provide a fallback. If any complete OPF heading role is recovered, untagged body text stays in `blocks[]` instead of being promoted into an absent heading role. Damaged tags retain visible text through ordinary import and diagnostics.
146
- - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
146
+ - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position. A measured list writes its size, family, weight and colour on every native run; in `full` provenance the slide record (`OPF_SLIDE_V1.listRuns`, opf-pptx#212) keeps that look per list (entry and description lines, never text), and import removes a run value still equal to it, so an untouched list round trip returns its items as authored instead of baking those values into `TextRun`s. A value edited in PowerPoint differs and comes back as observed; a run colour reference that resolves to the list colour (the `text` role) still returns through the run colour record, but an authored literal colour, size or family equal to the list's own cannot be told from it and returns absent. Without the record (`references-only`, `provenance: false`, a plain PPTX) runs are read as observed.
147
147
  - PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to OPF chart blocks whose `type` is the core chart type id for the native construct (for example a `percentStacked` column chart imports as `100pct-stacked-column`, a filled radar as `filled-radar`; a scatter chart imports as `[Point, X, ...series]`).
148
148
  - Chart cache points are placed by their native `c:pt@idx`, with `c:ptCount` retaining trailing gaps. Missing labels and values become `null`; explicitly empty labels and series names stay empty strings, and an actual numeric zero stays zero. Empty numeric cache values remain missing. Malformed or duplicate indices, contradictory counts, competing caches and allocation limits reject with `invalid-chart-cache` and the chart-part/cache path; hierarchical category caches reject with `unsupported-chart-cache` instead of flattening labels. Each cache is limited to 100,000 positions, and combined caches and the emitted table each to 1,000,000 cells. Complete decimal scientific notation in numeric caches is read as a number; exponent overflow or nonzero underflow to zero rejects with `unsupported-chart-cache` and the chart part, series, cache and logical point path. Zero coefficients remain zero. This finite-number boundary does not classify rejected text as invalid OOXML. Malformed exponent strings and other nonblank numeric text retain the existing legacy conversion policy. This cache-only import does not repair embedded worksheets, restore unsupported scatter X/point labels, change exporter/workbook parsing or export/re-export missing-data handling, or establish native chart fidelity.
149
149
  - Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
@@ -212,6 +212,29 @@ The seven kept chart types with no ECMA-376 construct are written as Office 2016
212
212
 
213
213
  The chart area, label colour, label font (`latin`/`ea`/`cs` in the chart's body font) and series colours follow the classic charts. Re-import (`fromPptx`) reads the chartex part first: the layoutIds name the OPF id (an owned `paretoLine` is `pareto`), `cx:strDim type="cat"` restores the categories and each `cx:numDim` a series, under the same 100,000-point and 1,000,000-cell bounds as classic chart caches; a lone value column comes back as authored. A chartex part with no OPF construct (sunburst) falls back to the `mc:Fallback` chart, or to a text placeholder when PowerPoint's own text fallback is all there is. Native PowerPoint rendering of each construct is confirmed through the program's bounded native sample, not by this package's tests.
214
214
 
215
+ ## Pass `fonts`, otherwise the estimate
216
+
217
+ Text measurement comes from one place: the `fonts` handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`, or `/fonts-browser`). Pass the same handle to preview, validation, pagination and `toPptx`, so that the exported text boxes break lines where the preview does:
218
+
219
+ ```js
220
+ import { loadFonts } from '@openpresentation/opf-render/fonts-node';
221
+ import { toSvg } from '@openpresentation/opf-render';
222
+ import { validate } from '@openpresentation/opf';
223
+ import { paginate } from '@openpresentation/opf/pagination';
224
+ import { toPptx } from '@openpresentation/opf-pptx';
225
+
226
+ const fonts = await loadFonts({ pack: 'office' });
227
+ const report = validate(deck, { fonts });
228
+ const { presentation } = paginate(deck, { fonts });
229
+ const svgs = toSvg(presentation, { fonts }); // preview
230
+ const pptx = await toPptx(presentation, { fonts }); // export: the same lines, as editable text
231
+ ```
232
+
233
+ One handle carries the measurement for core's `validate`, `paginate` and slide context, for opf-render's `toSvg`, `toPng` and `toPdf`, and for `toPptx`. The same handle gives identical text geometry across engines: every engine reads core's one composition, so it is the measurement that decides the lines. This package's `test/layout-parity.mjs` checks that the exported PowerPoint paragraphs agree with the SVG preview and with core's composed items, with and without `fonts`.
234
+
235
+ Without `fonts`, `toPptx` uses core's built-in estimate: 0.54 em per character, 0.62 em for capitals and digits, 0.32 em for a space, 1 em for CJK characters and zero for combining marks. That is deterministic and needs no font files and no renderer, but it is too narrow for some scripts (opf#566 tracks improving it), and the line breaks it chooses can differ from the faces PowerPoint draws. The PPTX still names the font family the document chose (`fonts` changes where lines break, not which font is named). Core's Node `convert` and the `opf` CLI prepare an office-pack handle for you; a library call to `toPptx` never loads fonts on its own, so pass the handle you want. See also [opf#364](https://github.com/OpenPresentation/opf/issues/364) and core's [measured fonts](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#pass-fonts-otherwise-the-estimate).
236
+
237
+
215
238
  ## Languages, right-to-left text and script fonts
216
239
 
217
240
  The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
@@ -0,0 +1,76 @@
1
+ // opf-pptx#175: the classic chart series in ECMA-376 schema order (Part 1, dml-chart.xsd).
2
+ //
3
+ // pptxgenjs-plus 4.3.4 writes a line series' c:dLbls before its c:marker, and gives radar series the bar-only
4
+ // c:invertIfNegative; the scatter X axis (a c:valAx) carries the category-axis-only c:auto, c:lblAlgn and c:noMultiLvlLbl.
5
+ // PowerPoint opens these parts, but stricter consumers (the Open XML SDK validator, LibreOffice, Keynote) may not. This runs
6
+ // last on each generated chart part, after every other chart rewrite: a series whose children are already in sequence keeps
7
+ // its bytes, and only a series that needs it is rebuilt (stable, so repeated c:dPt keep their order).
8
+
9
+ const SHARED = ['idx', 'order', 'tx', 'spPr'];
10
+ const BAR = [...SHARED, 'invertIfNegative', 'pictureOptions', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'shape', 'extLst'];
11
+ const LINE = [...SHARED, 'marker', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'smooth', 'extLst'];
12
+ const AREA = [...SHARED, 'pictureOptions', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'extLst'];
13
+ const RADAR = [...SHARED, 'marker', 'dPt', 'dLbls', 'cat', 'val', 'extLst'];
14
+ const SCATTER = [...SHARED, 'marker', 'dPt', 'dLbls', 'trendline', 'errBars', 'xVal', 'yVal', 'smooth', 'extLst'];
15
+ const PIE = [...SHARED, 'explosion', 'dPt', 'dLbls', 'cat', 'val', 'extLst'];
16
+
17
+ /** The CT_*Ser child sequence of each classic chart group. */
18
+ export const SERIES_SEQUENCES = Object.freeze({
19
+ barChart: BAR, bar3DChart: BAR,
20
+ lineChart: LINE, line3DChart: LINE,
21
+ areaChart: AREA, area3DChart: AREA,
22
+ radarChart: RADAR,
23
+ scatterChart: SCATTER,
24
+ pieChart: PIE, pie3DChart: PIE, doughnutChart: PIE, ofPieChart: PIE,
25
+ });
26
+
27
+ // The top-level children of an element body, each with the whitespace before it: [{name, text}], or null when the body is
28
+ // not a plain run of elements (text content, comments or an unbalanced tag), which is then left alone.
29
+ function children(body) {
30
+ const out = [];
31
+ const tag = /<(\/?)([A-Za-z_][\w.:-]*)\b[^>]*?(\/?)>/g;
32
+ let depth = 0, start = 0, name = null, match;
33
+ while ((match = tag.exec(body))) {
34
+ const [, close, tagName, selfClose] = match;
35
+ if (depth === 0) {
36
+ if (close || body.slice(start, match.index).trim()) return null;
37
+ name = tagName;
38
+ }
39
+ if (close) depth--;
40
+ else if (!selfClose) depth++;
41
+ if (depth < 0) return null;
42
+ if (depth === 0) {
43
+ out.push({name, text: body.slice(start, tag.lastIndex)});
44
+ start = tag.lastIndex;
45
+ }
46
+ }
47
+ if (depth !== 0 || body.slice(start).trim()) return null;
48
+ return {items: out, tail: body.slice(start)};
49
+ }
50
+
51
+ function orderSeries(ser, sequence) {
52
+ const body = ser.slice('<c:ser>'.length, -'</c:ser>'.length);
53
+ const parsed = children(body);
54
+ if (!parsed || parsed.items.some(item => !item.name.startsWith('c:'))) return ser;
55
+ const rank = item => sequence.indexOf(item.name.slice(2));
56
+ // A child the series type does not define (c:invertIfNegative in a radar series) is dropped; the rest sort stably by rank.
57
+ const kept = parsed.items.filter(item => rank(item) >= 0);
58
+ const ordered = kept.map((item, position) => ({item, position, rank: rank(item)}))
59
+ .sort((a, b) => a.rank - b.rank || a.position - b.position)
60
+ .map(entry => entry.item);
61
+ if (kept.length === parsed.items.length && ordered.every((item, index) => item === parsed.items[index])) return ser;
62
+ return `<c:ser>${ordered.map(item => item.text).join('')}${parsed.tail}</c:ser>`;
63
+ }
64
+
65
+ // CT_ValAx has no c:auto, c:lblAlgn or c:noMultiLvlLbl (they belong to CT_CatAx); the generator writes them on the scatter X axis.
66
+ const CATEGORY_AXIS_ONLY = /\s*<c:(?:auto|lblAlgn|noMultiLvlLbl)\b[^>]*\/>/g;
67
+
68
+ /** Every classic chart series of a chart part in its CT_*Ser sequence, and every c:valAx without category-axis children. */
69
+ export function orderChartSeries(xml) {
70
+ let out = xml;
71
+ for (const [group, sequence] of Object.entries(SERIES_SEQUENCES)) {
72
+ if (!out.includes(`<c:${group}>`)) continue;
73
+ out = out.replace(new RegExp(`<c:${group}>[\\s\\S]*?</c:${group}>`, 'g'), block => block.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => orderSeries(ser, sequence)));
74
+ }
75
+ return out.replace(/<c:valAx>[\s\S]*?<\/c:valAx>/g, axis => axis.replace(CATEGORY_AXIS_ONLY, ''));
76
+ }
@@ -373,6 +373,31 @@ export function validateListBreaks(value) {
373
373
  return value;
374
374
  }
375
375
 
376
+ // opf-pptx#212: `listRuns` = [[list path, {text: Look, description?: Look}]], the look the exporter writes on every run of a
377
+ // list's entry lines (`text`) and description lines (`description`) where the source states none: Look = {bold, color,
378
+ // fontSize, fontFamily} (color '#RRGGBB', fontSize in points). Import removes a run value still equal to it, so inherited
379
+ // formatting returns absent; a value edited in PowerPoint differs and is imported as observed. Never text.
380
+ const LOOK_KEYS = Object.freeze({
381
+ bold: value => typeof value === 'boolean',
382
+ color: value => typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value),
383
+ fontSize: value => Number.isFinite(value) && value > 0 && value < 10000,
384
+ fontFamily: value => typeof value === 'string' && value.length > 0 && value.length <= 200,
385
+ });
386
+ const validLook = value => object(value) && Object.keys(value).length > 0 && Object.entries(value).every(([key, item]) => LOOK_KEYS[key]?.(item) === true);
387
+
388
+ /** Throws when `value` is not a well-formed `listRuns` record (untrusted input). Returns it otherwise. */
389
+ export function validateListRuns(value) {
390
+ if (!Array.isArray(value) || !value.length || value.length > MAX_LIST_PATHS) throw Error('Invalid list runs record.');
391
+ const paths = new Set();
392
+ for (const entry of value) {
393
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !LIST_PATH.test(entry[0]) || paths.has(entry[0]) || !object(entry[1])
394
+ || Object.keys(entry[1]).some(key => key !== 'text' && key !== 'description') || !validLook(entry[1].text)
395
+ || (entry[1].description !== undefined && !validLook(entry[1].description))) throw Error('Invalid list runs entry.');
396
+ paths.add(entry[0]);
397
+ }
398
+ return value;
399
+ }
400
+
376
401
  // ---------------------------------------------------------------------------
377
402
  // Import: validation
378
403
 
@@ -116,7 +116,7 @@ export function chartEvidence(xml) {
116
116
  }
117
117
 
118
118
  /** Native cell text, formatting and table geometry, canonicalized independently of XML attribute order. */
119
- export function tableCellEvidence(table, relationships = new Map()) {
119
+ export function tableCellEvidence(table, relationships = new Map(), {form = 'current'} = {}) {
120
120
  const related = value => {
121
121
  const ids = new Set(), stack = [value];
122
122
  while (stack.length) {
@@ -132,8 +132,72 @@ export function tableCellEvidence(table, relationships = new Map()) {
132
132
  return [id, link ? {type: link.type, target: link.target, targetMode: link.targetMode} : null];
133
133
  }))} : {};
134
134
  };
135
- const properties = {properties: table?.['a:tblPr'], grid: table?.['a:tblGrid']};
136
- return array(table?.['a:tr']).map(row => array(row?.['a:tc']).map(cell => hash(canonical({properties, row: Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'a:tc')), cell, ...related({properties, cell})}))));
135
+ // 'raw' hashes the XML as written (records before opf-pptx#241); 'saved' rebuilds the form this exporter wrote from a
136
+ // PowerPoint-saved copy of it, so such a record still matches a cell the save left alone.
137
+ const read = form === 'raw' ? value => value : form === 'saved' ? exporterForm : nativeDefaults;
138
+ const properties = {properties: table?.['a:tblPr'], grid: read(table?.['a:tblGrid'])};
139
+ return array(table?.['a:tr']).map(row => array(row?.['a:tc']).map(cell => hash(canonical({properties, row: read(Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'a:tc'))), cell: read(cell), ...related({properties, cell})}))));
140
+ }
141
+
142
+ // opf-pptx#241: a PowerPoint save drops attributes that hold their schema default and adds identity extensions, so the same
143
+ // table would hash differently. The evidence reads the native table with those defaults applied: an absent value and its
144
+ // default are one value (on a:tcPr: anchor t, margins 0.1 / 0.05 inch, anchorCtr 0, horzOverflow clip, vert horz; on a:pPr:
145
+ // marL 0, indent 0, algn l), whitespace between child elements, and the a16:colId / a16:rowId extensions PowerPoint adds to a:gridCol and a:tr are not content.
146
+ // Attribute order never mattered (canonical sorts keys). Records written before this fix hash the raw XML (legacy), and the
147
+ // importer still accepts that form for a file nobody changed.
148
+ // What this exporter wrote on every cell before the evidence read defaults: anchor on a:tcPr, and the whitespace PptxGenJS leaves
149
+ // after a:tcPr's children and after a:tcPr. A PowerPoint save removes all three; putting them back reproduces the recorded hash.
150
+ function exporterForm(value) {
151
+ const cleaned = nativeDefaults(value, false);
152
+ const rebuild = item => {
153
+ if (Array.isArray(item)) return item.map(rebuild);
154
+ if (!object(item)) return item;
155
+ const out = {};
156
+ for (const [key, child] of Object.entries(item)) out[key] = rebuild(child);
157
+ if (Object.hasOwn(out, 'a:txBody') && Object.hasOwn(out, 'a:tcPr') && !Object.hasOwn(out, '#text')) {
158
+ const properties = object(out['a:tcPr']) ? {...out['a:tcPr']} : {};
159
+ if (!Object.hasOwn(properties, 'anchor')) properties.anchor = 't';
160
+ if (!Object.hasOwn(properties, '#text') && Object.keys(properties).some(name => name.startsWith('a:'))) properties['#text'] = ' ';
161
+ out['a:tcPr'] = properties;
162
+ out['#text'] = ' ';
163
+ }
164
+ return out;
165
+ };
166
+ return rebuild(cleaned);
167
+ }
168
+ const PROPERTY_DEFAULTS = {
169
+ 'a:tcPr': {marL: '91440', marR: '91440', marT: '45720', marB: '45720', anchor: 't', anchorCtr: '0', horzOverflow: 'clip', vert: 'horz'},
170
+ 'a:pPr': {marL: '0', indent: '0', algn: 'l'}
171
+ };
172
+ const IDENTITY_EXTENSIONS = new Set(['a16:colId', 'a16:rowId']);
173
+ const attributeValue = (name, value) => {
174
+ const text = String(value).trim();
175
+ return name === 'anchorCtr' ? ({false: '0', off: '0', true: '1', on: '1'}[text.toLowerCase()] ?? text) : text;
176
+ };
177
+ function nativeDefaults(value, normalize = true) {
178
+ if (Array.isArray(value)) return value.map(item => nativeDefaults(item, normalize));
179
+ if (!object(value)) return value;
180
+ const out = {};
181
+ // Whitespace between child elements is not content (the exporter leaves some inside a:tcPr and a:tc; a save removes it).
182
+ const hasChildren = Object.keys(value).some(name => /^[a-z][w-]*:/i.test(name) && !/^xml(ns)?:/i.test(name));
183
+ for (const [key, item] of Object.entries(value)) {
184
+ if (key === '#text' && normalize && hasChildren && typeof item === 'string' && !item.trim()) continue;
185
+ if (key === 'a:extLst') {
186
+ const kept = array(item?.['a:ext']).filter(ext => !(object(ext) && Object.keys(ext).filter(name => name !== 'uri').every(name => IDENTITY_EXTENSIONS.has(name))));
187
+ if (kept.length) out[key] = {...item, 'a:ext': nativeDefaults(kept.length === 1 ? kept[0] : kept, normalize)};
188
+ continue;
189
+ }
190
+ out[key] = nativeDefaults(item, normalize);
191
+ }
192
+ if (!normalize) return out;
193
+ for (const [tag, defaults] of Object.entries(PROPERTY_DEFAULTS)) {
194
+ if (!Object.hasOwn(out, tag)) continue;
195
+ const properties = object(out[tag]) ? {...out[tag]} : {};
196
+ for (const [name, fallback] of Object.entries(defaults)) if (Object.hasOwn(properties, name) && attributeValue(name, properties[name]) === fallback) delete properties[name];
197
+ // No attributes and no children is the same as an empty element (the parser reads `<a:tcPr/>` as '').
198
+ out[tag] = Object.keys(properties).length ? properties : '';
199
+ }
200
+ return out;
137
201
  }
138
202
 
139
203
  /** Theme and table-style edits can change effective cell formatting without changing the cell XML. */
@@ -372,8 +436,11 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
372
436
  }
373
437
 
374
438
  /** The authored table data where the native evidence is unchanged; edited native cells otherwise. */
375
- export function restoreTableData(table, record, {datasets, report, evidence, environment}) {
439
+ export function restoreTableData(table, record, {datasets, report, evidence: currentEvidence, olderEvidence = [], environment}) {
376
440
  if (!object(table)) return table;
441
+ // opf-pptx#241: a record written before the evidence read schema defaults hashes the raw XML (or, for a PowerPoint-saved copy, the
442
+ // form rebuilt from it); a cell still matching one of those is unchanged.
443
+ const evidence = currentEvidence?.map((row, r) => row.map((hash, c) => olderEvidence.map(older => older?.[r]?.[c]).find(older => older !== undefined && older === record.evidence?.[r]?.[c]) ?? hash));
377
444
  const columns = Array.isArray(table.columns) ? table.columns : undefined;
378
445
  const rows = Array.isArray(table.rows) ? table.rows : [];
379
446
  const sameCell = (row, column) => record.environment === environment && record.evidence?.[row]?.[column] === evidence?.[row]?.[column];
@@ -1,6 +1,6 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
- import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
3
+ import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks, validateListRuns, MAX_LIST_PATHS} from './content-topology.js';
4
4
  import {runColorRecord, validateRunColors} from './run-colors.js';
5
5
  import {validateCatalogRecord} from '@openpresentation/opf';
6
6
  import {parseReference, resolveReference, resolveSocialProfile} from '@openpresentation/opf/composition';
@@ -548,6 +548,17 @@ export function recordContentTopology(provenance, slide, index, items) {
548
548
  if (omittedLists.length) provenance.slides[index].omittedLists = omittedLists;
549
549
  }
550
550
 
551
+ /**
552
+ * opf-pptx#212: record the look the exporter writes on every run of list `path` on slide `index` where the source states none
553
+ * ({text, description?}, see validateListRuns), `full` mode only. Import removes a run value still equal to it.
554
+ */
555
+ export function recordListRuns(provenance, index, path, look) {
556
+ if (!look || !provenance || provenance.mode !== 'full' || !provenance.slides[index]) return;
557
+ const record = provenance.slides[index];
558
+ record.listRuns ??= [];
559
+ if (record.listRuns.length < MAX_LIST_PATHS && !record.listRuns.some(([list]) => list === path)) record.listRuns.push([path, look]);
560
+ }
561
+
551
562
  function base64ToBytes(value) {
552
563
  const binary = atob(value.replace(/\s+/g, ''));
553
564
  const bytes = new Uint8Array(binary.length);
@@ -698,7 +709,7 @@ function storable(entries, provenance) {
698
709
  const result = {v: 1, slide: index};
699
710
  if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
700
711
  for (const [path, reason] of record.omittedLists ?? []) omit(path, reason);
701
- for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists']) {
712
+ for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns']) {
702
713
  if (record[key] === undefined) continue;
703
714
  const value = prepare(`slides.${index}.${key}`, record[key]);
704
715
  if (value !== undefined) result[key] = value;
@@ -735,7 +746,7 @@ function storable(entries, provenance) {
735
746
  }
736
747
  for (const [index, record] of slides.entries()) {
737
748
  while (tagChars(record) > budget) {
738
- const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
749
+ const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
739
750
  ...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
740
751
  if (!candidates.length) break;
741
752
  const largest = candidates.sort((a, b) => b.size - a.size)[0];
@@ -874,6 +885,7 @@ function validateSlide(stored) {
874
885
  if (value.content !== undefined) validateTopology(value.content);
875
886
  if (value.colors !== undefined) validateRunColors(value.colors);
876
887
  if (value.lists !== undefined) validateListBreaks(value.lists);
888
+ if (value.listRuns !== undefined) validateListRuns(value.listRuns);
877
889
  validateOmitted(value.omitted);
878
890
  return value;
879
891
  }
@@ -914,19 +926,23 @@ function pruneDangling(value, dangling, path, removed) {
914
926
  }
915
927
 
916
928
  /**
917
- * The hard line breaks stored for the lists of one slide: Map(list path -> Map(line number -> gap)), or null when the slide
918
- * carries none, its record is unreadable, or its arrangement changed since export (the lines are then no longer the exported
919
- * ones). Read before the slide's shapes are merged into list blocks; any damage is reported later by restoreDocumentProvenance.
929
+ * The list records of one slide, read before its shapes are merged into list blocks:
930
+ * - `breaks`: the hard line breaks, Map(list path -> Map(line number -> gap)), or null when the slide carries none, its record is
931
+ * unreadable, or its arrangement changed since export (the lines are then no longer the exported ones);
932
+ * - `runs` (opf-pptx#212): the look export wrote on every run of each list, Map(list path -> {text, description?}), or null. It
933
+ * does not depend on the arrangement: import compares each run value with it, so an edited value is still observed.
934
+ * Any damage is reported later by restoreDocumentProvenance.
920
935
  */
921
- export function slideListBreaks(entries, path, root, rels) {
936
+ export function slideListRecord(entries, path, root, rels) {
922
937
  try {
923
938
  const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
924
- if (found.missing) return null;
939
+ if (found.missing) return {breaks: null, runs: null};
925
940
  const record = validateSlide(found.value);
926
- if (record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure) return null;
927
- return new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
941
+ const runs = record.listRuns === undefined ? null : new Map(record.listRuns);
942
+ const breaks = record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure ? null : new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
943
+ return {breaks, runs};
928
944
  } catch {
929
- return null;
945
+ return {breaks: null, runs: null};
930
946
  }
931
947
  }
932
948
 
@@ -271,3 +271,57 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
271
271
  }
272
272
  return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
273
273
  }
274
+
275
+ // opf-pptx#221: the "Image unavailable" placeholder of an image block whose source could not be embedded (a remote URL is never
276
+ // fetched) is one native group, `OPF image placeholder N group`, that carries the panel's accessible name (descr) and holds the
277
+ // dashed panel `OPF image placeholder N` and its label lines (`... text line i`) or cross strokes (`... icon i`), each an
278
+ // editable shape. In `full` mode the panel carries OPF_IMAGE_PLACEHOLDER_V1: the slide, the block path, the authored image value
279
+ // and the block's own fields. Import turns an intact tagged panel back into that image block and consumes its label shapes;
280
+ // without a valid tag (provenance false or references-only, a damaged tag) the shapes stay ordinary content, as before.
281
+ export const IMAGE_PLACEHOLDER_TAG = 'OPF_IMAGE_PLACEHOLDER_V1';
282
+ export const imagePlaceholderGroupName = panel => `${panel} group`;
283
+ const PLACEHOLDER_PANEL = /^OPF image placeholder \d+$/;
284
+ const PLACEHOLDER_GROUP = /^OPF image placeholder \d+ group$/;
285
+ const BLOCK_PATH = /^slides\.\d+(?:\.[A-Za-z0-9_:+-]{1,64}){1,32}$/;
286
+ const placeholderPart = (panel, name) => typeof name === 'string' && name.startsWith(`${panel} `) && /^(?:text line|icon) \d+$/.test(name.slice(panel.length + 1));
287
+
288
+ /** Whether a native group is an exported image placeholder group with an identity transform (its children keep their boxes). */
289
+ export function isImagePlaceholderGroup(group) {
290
+ if (!PLACEHOLDER_GROUP.test(String(group?.['p:nvGrpSpPr']?.['p:cNvPr']?.name ?? ''))) return false;
291
+ const xfrm = group?.['p:grpSpPr']?.['a:xfrm'];
292
+ return ['x', 'y'].every(key => String(xfrm?.['a:off']?.[key]) === String(xfrm?.['a:chOff']?.[key]))
293
+ && ['cx', 'cy'].every(key => String(xfrm?.['a:ext']?.[key]) === String(xfrm?.['a:chExt']?.[key]));
294
+ }
295
+
296
+ /**
297
+ * Recover the image blocks of tagged unavailable-image placeholders on one slide. `shapes` are the slide's native text shapes
298
+ * (nativeTextShapes order, group members included); `valid(payload)` checks a recovered image block against the OPF format.
299
+ * Returns `items`: [{panel (shape index), members (shape indexes, the panel first), payload}] and `consumed`, every member index.
300
+ */
301
+ export function importImagePlaceholders({shapes, relationships, entries, slideIndex, valid, report}) {
302
+ const slide = `slides.${slideIndex}`;
303
+ const items = [], consumed = new Set();
304
+ const names = shapes.map(shape => shape?.['p:nvSpPr']?.['p:cNvPr']?.name);
305
+ const invalid = message => report({code: 'invalid-image-provenance', path: slide, message});
306
+ for (const [index, shape] of shapes.entries()) {
307
+ let manifest;
308
+ try { manifest = ownTag(shape?.['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_PLACEHOLDER_TAG); }
309
+ catch { invalid('A tagged OPF image placeholder is ambiguous; its shapes were imported as ordinary content.'); continue; }
310
+ if (manifest === null) continue;
311
+ const name = names[index];
312
+ const image = manifest?.image;
313
+ const treatment = manifest?.treatment ?? {};
314
+ if (manifest?.v !== 1 || manifest.slide !== slide || typeof manifest.path !== 'string' || !BLOCK_PATH.test(manifest.path) || !manifest.path.startsWith(`${slide}.`)
315
+ || !PLACEHOLDER_PANEL.test(name ?? '') || !(typeof image === 'string' || object(image)) || !object(treatment)
316
+ || Object.keys(treatment).some(key => !IMAGE_TREATMENT_KEYS.includes(key))) {
317
+ invalid('An edited or invalid tagged OPF image placeholder was imported as ordinary content; its image block is not reconstructed.');
318
+ continue;
319
+ }
320
+ const payload = {type: 'image', image: structuredClone(image), ...structuredClone(treatment)};
321
+ if (!valid(payload)) { invalid('A tagged OPF image placeholder does not form a valid image block; its shapes were imported as ordinary content.'); continue; }
322
+ const members = [index, ...names.flatMap((other, position) => position !== index && placeholderPart(name, other) ? [position] : [])];
323
+ for (const member of members) consumed.add(member);
324
+ items.push({panel: index, members, payload});
325
+ }
326
+ return {items, consumed};
327
+ }
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineCha
10
10
  import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord,tableCellEvidence,tableEnvironmentEvidence} from './data-provenance.js';
11
11
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
12
12
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
13
- import {applyFrameAlt,readFrameAlt} from './frame-alt.js';
13
+ import {applyFrameAlt,readFrameAlt,writeFrameAlt} from './frame-alt.js';
14
14
  import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
15
15
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
16
16
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
@@ -20,27 +20,28 @@ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTex
20
20
  import {addCaption, addFootnotes} from './annotation-export.js';
21
21
  import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
22
22
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
23
- import {headingValue,joinRichLines} from './rich-heading.js';
23
+ import {cleanBase,headingValue,joinRichLines} from './rich-heading.js';
24
24
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
25
25
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
26
26
  import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './quote-provenance.js';
27
27
  import {attachFurnitureTags, furnitureManifest, importFurniture, logoShape, staticDateFallback, stampFurnitureImageKeys} from './furniture-provenance.js';
28
28
  import {restoreRunColors} from './run-colors.js';
29
29
  import {joinWrappedText} from './content-topology.js';
30
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
30
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, recordListRuns, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListRecord, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
31
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
32
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
33
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
34
  import {liftMasterFurniture} from './master-furniture.js';
35
35
  import {importImageOrientation} from './image-import.js';
36
36
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
37
- import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
37
+ import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
38
38
  import {dedupeMedia} from './media-dedupe.js';
39
39
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
40
40
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
41
41
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
42
42
  import {importBackground} from './background-import.js';
43
43
  import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
44
+ import {orderChartSeries} from './chart-order.js';
44
45
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
45
46
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
46
47
  import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
@@ -255,6 +256,9 @@ export async function toPptx(input, options = {}) {
255
256
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
256
257
  context.listMarkers = new Map();
257
258
  context.imagePlaceholders = new Map();
259
+ // opf-pptx#221: the placeholder panels written as one native group, and the OPF_IMAGE_PLACEHOLDER_V1 records of image blocks.
260
+ context.imagePlaceholderGroups = new Set();
261
+ context.imagePlaceholderTags = new Map();
258
262
  context.tableHeaders = new Map();
259
263
  context.tableCells = new Map();
260
264
  // FA-23: every exported image picture (image blocks, the alt-text form of an image background, timeline pictures) and
@@ -815,8 +819,8 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
815
819
  item.text = current.map(paragraph => paragraph.text).join('\n');
816
820
  }
817
821
  }
818
- const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
819
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
822
+ const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
823
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs))
820
824
  .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
821
825
  .filter((entry) => entry.payload);
822
826
  const blocks = content.map((entry) => entry.payload);
@@ -860,7 +864,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
860
864
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
861
865
  const items = [];
862
866
  const shapes = nativeContext.shapes;
863
- if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
867
+ // opf-pptx#221: an exported image placeholder group (identity transform) is not a reflow risk.
868
+ if (asArray(tree?.['p:grpSp']).some(group => !isImagePlaceholderGroup(group))) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
864
869
  const paragraphs = nativeContext.paragraphs;
865
870
  // FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
866
871
  // layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
@@ -914,11 +919,17 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
914
919
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
915
920
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
916
921
  for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
922
+ // opf-pptx#221: a tagged unavailable-image placeholder is its image block again; its panel and label shapes are consumed.
923
+ const placeholders = importImagePlaceholders({shapes, relationships, entries, slideIndex, valid: payload => isValidFormat({slides: [{blocks: [payload]}]}), report: diagnostic => options.onDiagnostic?.(diagnostic)});
924
+ for (const item of placeholders.items) {
925
+ for (const member of item.members) roles.set(`sp:${member}`, 'image');
926
+ items.push({kind: 'image', bounds: shapeBounds(shapes[item.panel]['p:spPr']?.['a:xfrm']), payload: item.payload, sources: item.members.map(member => `sp:${member}`)});
927
+ }
917
928
  for (const [index,shape] of shapes.entries()) {
918
929
  const role = furniture.text.has(index) ? 'furniture' : nativeContext.imageShapes?.has(index) ? 'image-overlay' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
919
930
  : code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
920
931
  if (role) roles.set(shapeKeys.get(shape), role);
921
- if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
932
+ if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index) || placeholders.consumed.has(index)) continue;
922
933
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
923
934
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
924
935
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -1020,7 +1031,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
1020
1031
  };
1021
1032
  if (table) {
1022
1033
  const stored = importedTable && record('table');
1023
- const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
1034
+ const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), olderEvidence: [tableCellEvidence(table, relationships, {form: 'raw'}), tableCellEvidence(table, relationships, {form: 'saved'})], environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
1024
1035
  // FA-27: the frame's descr (or PowerPoint's decorative marker) is the table's text alternative.
1025
1036
  const alt = restoredTable && typeof restoredTable === 'object' ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
1026
1037
  return {
@@ -1240,10 +1251,13 @@ function joinRich(a, b) {
1240
1251
  }
1241
1252
  return joined;
1242
1253
  }
1243
- // `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1254
+ // `breaks` (slideListRecord) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1244
1255
  // continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
1245
1256
  // native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
1246
- function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1257
+ // `runs` (opf-pptx#212) holds the look export wrote on every run of a list ({text, description?}): a run value still equal to
1258
+ // it is inherited formatting, not authored run style, and is removed (as for a tagged heading), so an untouched round trip
1259
+ // adds no explicit sizes, families or colours; a value edited in PowerPoint differs and is kept. Without it runs are as read.
1260
+ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null) {
1247
1261
  const lists = new Map();
1248
1262
  for (const item of items) {
1249
1263
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -1256,6 +1270,26 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1256
1270
  for (const [path, entries] of lists) {
1257
1271
  entries.sort((a, b) => a.line - b.line);
1258
1272
  if (!entries[0].item.paragraphs[0].bullet) continue;
1273
+ const look = runs?.get(path), textLook = cleanBase(look?.text), descriptionLook = cleanBase(look?.description);
1274
+ // The line's runs without the inherited look, or the paragraph's own richText when there is no record for this kind of line.
1275
+ // Each run is cleaned on its own and none merge, so the runs stay the slots the run colour record counts (src/run-colors.js);
1276
+ // they stay run objects until the lines are joined (as before), then `plain` gives the authored form back.
1277
+ const own = (paragraph, base) => {
1278
+ if (!base) return paragraph.richText;
1279
+ return richRuns(paragraph.richText ?? paragraph.text).flatMap(run => {
1280
+ const value = headingValue([run], base);
1281
+ const parts = value === '' ? [] : typeof value === 'string' ? [{text: value}] : value.map(part => typeof part === 'string' ? {text: part} : part);
1282
+ // A removed colour stays known until the run colour references are restored (restoreRunColors): an authored `text`
1283
+ // role equals the inherited colour natively.
1284
+ if (typeof run === 'object' && typeof run.color === 'string' && parts.length === 1 && parts[0].color === undefined) parts[0]._opfInherited = run.color;
1285
+ return parts;
1286
+ });
1287
+ };
1288
+ const plain = value => {
1289
+ if (!Array.isArray(value)) return value;
1290
+ const runs = value.map(run => typeof run === 'object' && Object.keys(run).length === 1 ? run.text : run);
1291
+ return runs.length === 0 ? '' : runs.length === 1 && typeof runs[0] === 'string' ? runs[0] : runs;
1292
+ };
1259
1293
  const paragraphs = [];
1260
1294
  const recorded = breaks?.get(path), applied = new Set();
1261
1295
  let bounds, size = 0;
@@ -1263,16 +1297,16 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1263
1297
  const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
1264
1298
  const gap = recorded?.get(line);
1265
1299
  const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
1266
- if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
1300
+ if (paragraph.bullet) { paragraphs.push({...paragraph, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
1267
1301
  else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
1268
1302
  else if (paragraph.maxFontSize < size - 0.01) {
1269
- const text = paragraph.richText ?? paragraph.text;
1303
+ const text = own(paragraph, descriptionLook) ?? paragraph.text;
1270
1304
  if (current.description === undefined) current.description = text;
1271
1305
  else { current.description = join(current.description, text); applied.add(line); }
1272
1306
  } else {
1273
- const before = current.text;
1307
+ const before = current.text, rich = own(paragraph, textLook);
1274
1308
  current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
1275
- if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
1309
+ if (current.richText !== undefined || rich !== undefined) current.richText = join(current.richText ?? before, rich ?? paragraph.text);
1276
1310
  applied.add(line);
1277
1311
  }
1278
1312
  const b = item.visualBounds ?? item.bounds;
@@ -1283,6 +1317,8 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1283
1317
  consumed.add(item);
1284
1318
  }
1285
1319
  if (recorded && [...recorded.keys()].some(line => !applied.has(line))) report({code: 'list-line-break-changed', message: `A hard line break recorded at export inside the list ${path} no longer has the continuation line it belonged to (a line was edited away, or became an entry), so that break was not restored; the list keeps the lines it has.`});
1320
+ if (textLook) for (const paragraph of paragraphs) paragraph.richText = plain(paragraph.richText);
1321
+ if (descriptionLook) for (const paragraph of paragraphs) if (paragraph.description !== undefined) paragraph.description = plain(paragraph.description);
1286
1322
  const first = entries[0].item;
1287
1323
  merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
1288
1324
  }
@@ -2029,6 +2065,8 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2029
2065
  });
2030
2066
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
2031
2067
  await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
2068
+ // opf-pptx#212: the look every run of the list is written with where its source states none (full provenance only).
2069
+ if(item.textStyle)recordListRuns(context.documentProvenance,slideIndex,item.path,listRunLook(item.text,item.textStyle,itemContext));
2032
2070
  } else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
2033
2071
  // FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
2034
2072
  const heading = item.field !== 'text';
@@ -2163,6 +2201,14 @@ function addTextPayload(slide, value, region, context) {
2163
2201
  slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
2164
2202
  }
2165
2203
 
2204
+ // opf-pptx#212: the look (richBase) the runs of a measured list are written with where the source states none: {text,
2205
+ // description?}, or undefined when its entries do not share one (import then reads every run value as observed).
2206
+ function listRunLook(fit,style,context){
2207
+ const looks=kind=>new Set(fit.listEntries.filter(entry=>entry[kind]).map(entry=>JSON.stringify(richBase(entry[kind],style,kind==='text'?context.colors.text:context.colors.mutedText))));
2208
+ const text=looks('text'),description=looks('description');
2209
+ if(text.size!==1||description.size>1)return undefined;
2210
+ return {text:JSON.parse([...text][0]),...(description.size?{description:JSON.parse([...description][0])}:{})};
2211
+ }
2166
2212
  function richLineRuns(line,color,native,context) {
2167
2213
  const fallback=color.replace(/^#/,'');
2168
2214
  return line.fragments.map(fragment=>{
@@ -2266,9 +2312,10 @@ function addListPayload(slide, items, region, context) {
2266
2312
  }
2267
2313
 
2268
2314
  // A content picture outside an image block (a timeline event image): one native picture contained in its region.
2269
- async function addImagePayload(slide, presentation, asset, region, path, context, options) {
2315
+ // Generated furniture is never grouped (src/master-furniture.js), so a furniture part passes {group: false}.
2316
+ async function addImagePayload(slide, presentation, asset, region, path, context, options, {group = true} = {}) {
2270
2317
  const name = await addImagePicture(slide, presentation, asset, region, path, context, options, {fit: 'contain'});
2271
- if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2318
+ if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options, {group});
2272
2319
  return name;
2273
2320
  }
2274
2321
 
@@ -2348,8 +2395,13 @@ async function addImageItem(slide, presentation, item, slideIndex, context, opti
2348
2395
  fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2349
2396
  });
2350
2397
  if (!name) {
2351
- addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2352
- return `OPF image placeholder ${context.imagePlaceholders.size}`;
2398
+ const panel = addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2399
+ // opf-pptx#221: in full mode the panel records the authored image and the block's own fields, so import restores the block.
2400
+ if (context.provenanceMode === 'full') {
2401
+ const fields = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2402
+ context.imagePlaceholderTags.set(panel, {v: 1, slide: `slides.${slideIndex}`, path: item.path, image: structuredClone(item.value), ...(Object.keys(fields).length ? {treatment: fields} : {})});
2403
+ }
2404
+ return panel;
2353
2405
  }
2354
2406
  if (image.overlay) addImageOverlay(slide, image.overlay, structuredClone(block.overlay), imageOverlayName(name), name, { ...context, slidePath: `slides.${slideIndex}` }, effects);
2355
2407
  return name;
@@ -2858,7 +2910,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2858
2910
  if(part.type==='image'){
2859
2911
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
2860
2912
  // A logo reference draws the organization's asset: a source that cannot be drawn is reported where the asset is written.
2861
- const objectName = await addImagePayload(slide,presentation,part.image,region,part.reference!==undefined?part.sourcePath:part.path,context,options);
2913
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.reference!==undefined?part.sourcePath:part.path,context,options,{group:false});
2862
2914
  if (objectName) {
2863
2915
  entry.names.push(objectName);
2864
2916
  context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
@@ -3130,9 +3182,10 @@ function placeholderAsset(asset, presentation) {
3130
3182
  // minimum) in the readable text colour for the panel, or a cross when the label
3131
3183
  // cannot fit even at that minimum. The panel carries the accessible name the
3132
3184
  // preview gives its group, "Image unavailable: <description>".
3133
- // {logo: true} names the panel's other shapes after its panel (OPF image placeholder N text line i / icon i) and returns the
3134
- // panel's name, so a logo placeholder can be tagged and consumed on import.
3135
- function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false} = {}) {
3185
+ // The panel's other shapes are named after it (OPF image placeholder N text line i / icon i) and the panel's name is returned,
3186
+ // so a logo placeholder can be tagged and consumed on import. opf-pptx#221: unless it is a logo's ({logo: true}) or furniture's
3187
+ // ({group: false}) placeholder, packaging writes the panel and those shapes as one native group carrying the accessible name.
3188
+ function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false, group = !logo} = {}) {
3136
3189
  const layered = placeholderAsset(asset, presentation);
3137
3190
  const description = String(layered.alt ?? layered.title ?? "Image");
3138
3191
  const label = `Image unavailable\n${description}`;
@@ -3153,6 +3206,8 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
3153
3206
  const fit = fitText(label, inner, 20 * scale, minimum, measurer);
3154
3207
  const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
3155
3208
  context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
3209
+ if (group) context.imagePlaceholderGroups.add(objectName);
3210
+ const named = logo || group;
3156
3211
  slide.addShape("rect", {
3157
3212
  x: region.x, y: region.y, w: region.w, h: region.h,
3158
3213
  fill: { color: surface },
@@ -3166,18 +3221,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
3166
3221
  const centered = { ...inner, y: inner.y + offset };
3167
3222
  addMeasuredPayloadText(slide, label, centered, context, options, {
3168
3223
  path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
3169
- diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
3224
+ diagnosticsHandled: true, align: "center", color: textColor, ...(named ? {objectName: `${objectName} text`} : {})
3170
3225
  });
3171
- return logo ? objectName : undefined;
3226
+ return objectName;
3172
3227
  }
3173
3228
  // A status indicator, not shortened authored content: the full description stays
3174
3229
  // in the panel's accessible name.
3175
3230
  const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
3176
- if (!size) return logo ? objectName : undefined;
3231
+ if (!size) return objectName;
3177
3232
  const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
3178
3233
  const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
3179
- for (const flipV of [false, true]) slide.addShape("line", { x: icon.x / 96, y: icon.y / 96, w: size / 96, h: size / 96, line, flipV, ...(logo ? {objectName: `${objectName} icon ${flipV ? 2 : 1}`} : {}) });
3180
- return logo ? objectName : undefined;
3234
+ for (const flipV of [false, true]) slide.addShape("line", { x: icon.x / 96, y: icon.y / 96, w: size / 96, h: size / 96, line, flipV, ...(named ? {objectName: `${objectName} icon ${flipV ? 2 : 1}`} : {}) });
3235
+ return objectName;
3181
3236
  }
3182
3237
 
3183
3238
  // A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
@@ -3847,6 +3902,8 @@ async function normalizePptxZip(raw, context) {
3847
3902
  context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
3848
3903
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
3849
3904
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
3905
+ // opf-pptx#221: the image block of an unavailable-image placeholder, on its panel (full provenance).
3906
+ attachTextTags(entries,context.imagePlaceholderTags,IMAGE_PLACEHOLDER_TAG,'opfImagePlaceholder','image placeholder');
3850
3907
  for(const [part,bytes]of Object.entries(entries)){
3851
3908
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
3852
3909
  const relationships=parseRelationships(entries,part);
@@ -3879,6 +3936,12 @@ async function normalizePptxZip(raw, context) {
3879
3936
  }
3880
3937
  }
3881
3938
  }
3939
+ // opf-pptx#175: after every chart rewrite, each classic series in its CT_*Ser sequence (src/chart-order.js).
3940
+ for(const part of Object.keys(entries)){
3941
+ if(!/^ppt\/charts\/chart\d+\.xml$/.test(part))continue;
3942
+ const xml=decodeText(entries[part]),ordered=orderChartSeries(xml);
3943
+ if(ordered!==xml)entries[part]=encodeText(ordered);
3944
+ }
3882
3945
  applyChartFonts(entries,context.chartFonts,parseRelationships);
3883
3946
  if (context.frameAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyFrameAlt(xml, context.frameAlts); if (next !== xml) entries[part] = encodeText(next); }
3884
3947
  // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
@@ -4223,12 +4286,18 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4223
4286
  });
4224
4287
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
4225
4288
  // carries the accessible name the preview gives its group.
4289
+ const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
4290
+ const describe=description=>description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char]);
4226
4291
  xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)"(\/?)>/g,(node,id,name,close)=>{
4227
4292
  const description=context.imagePlaceholders.get(name);
4228
4293
  if(description===undefined)return node;
4229
- const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
4230
- return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}"${close}>`;
4294
+ // opf-pptx#221: a grouped placeholder's accessible name is on its group (below), as the preview names its <g>. The empty panel
4295
+ // then has no alt text of its own, so it is marked decorative (the adec:decorative extension, "Mark as decorative"), or
4296
+ // PowerPoint's Accessibility Checker flags it. The text lines carry text and are not marked. PptxGenJS writes this cNvPr self-closing.
4297
+ if(context.imagePlaceholderGroups.has(name))return close?writeFrameAlt(node,''):node;
4298
+ return `<p:cNvPr id="${id}" name="${name}" descr="${describe(description)}"${close}>`;
4231
4299
  });
4300
+ xml=groupImagePlaceholders(xml,context.imagePlaceholderGroups,name=>describe(context.imagePlaceholders.get(name)));
4232
4301
  // PptxGenJS 4.0.1 emitted pPr before each rich run (pptxgenjs-plus writes
4233
4302
  // one). OOXML allows one pPr, before all runs. Paragraph options belong
4234
4303
  // to the first run.
@@ -4256,6 +4325,34 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4256
4325
  // their explicit per-shape buFont; only the master bodyStyle is touched.
4257
4326
  const VENDOR_MASTER_BULLET_FONT = '<a:buFont typeface="Arial" pitchFamily="34" charset="0"/>';
4258
4327
  const THEME_MINOR_BULLET_FONT = '<a:buFont typeface="+mn-lt"/>';
4328
+ // opf-pptx#221: each unavailable-image placeholder (panel, then its label lines or cross strokes, written consecutively) becomes
4329
+ // one p:grpSp (CT_GroupShape: nvGrpSpPr, grpSpPr, members) named "<panel> group" with the accessible name as its descr. Its
4330
+ // transform is the identity (chOff/chExt equal off/ext, the members' union), so every member keeps its box and stays an
4331
+ // editable shape; tags and names stay on the members.
4332
+ function groupImagePlaceholders(xml,panels,description){
4333
+ if(!panels.size)return xml;
4334
+ let next=Math.max(0,...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match=>Number(match[1])))+1;
4335
+ const nameOf=shape=>shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1]??'';
4336
+ const memberOf=(panel,name)=>name.startsWith(`${panel} `)&&/^(?:text line|icon) \d+$/.test(name.slice(panel.length+1));
4337
+ return xml.replace(/<p:sp>[\s\S]*?<\/p:sp>(?:<p:sp>[\s\S]*?<\/p:sp>)*/g,run=>{
4338
+ const shapes=run.match(/<p:sp>[\s\S]*?<\/p:sp>/g);
4339
+ let out='';
4340
+ for(let index=0;index<shapes.length;index++){
4341
+ const panel=nameOf(shapes[index]);
4342
+ if(!panels.has(panel)){out+=shapes[index];continue;}
4343
+ const members=[shapes[index]];
4344
+ while(index+1<shapes.length&&memberOf(panel,nameOf(shapes[index+1])))members.push(shapes[++index]);
4345
+ const boxes=members.map(shape=>shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/)).filter(Boolean).map(match=>match.slice(1).map(Number));
4346
+ if(!boxes.length){out+=members.join('');continue;}
4347
+ const x=Math.min(...boxes.map(box=>box[0])),y=Math.min(...boxes.map(box=>box[1]));
4348
+ const cx=Math.max(...boxes.map(box=>box[0]+box[2]))-x,cy=Math.max(...boxes.map(box=>box[1]+box[3]))-y;
4349
+ out+=`<p:grpSp><p:nvGrpSpPr><p:cNvPr id="${next++}" name="${imagePlaceholderGroupName(panel)}" descr="${description(panel)}"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>`+
4350
+ `<p:grpSpPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/><a:chOff x="${x}" y="${y}"/><a:chExt cx="${cx}" cy="${cy}"/></a:xfrm></p:grpSpPr>${members.join('')}</p:grpSp>`;
4351
+ }
4352
+ return out;
4353
+ });
4354
+ }
4355
+
4259
4356
  function themeMasterBulletFonts(xml) {
4260
4357
  return xml.replace(/<p:bodyStyle>[\s\S]*?<\/p:bodyStyle>/, bodyStyle =>
4261
4358
  bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
@@ -87,7 +87,7 @@ export function validateRunColors(value) {
87
87
  function* markedRuns(value) {
88
88
  if (Array.isArray(value)) { for (const item of value) yield* markedRuns(item); return; }
89
89
  if (!object(value)) return;
90
- if (Object.hasOwn(value, '_opfScheme')) yield value;
90
+ if (Object.hasOwn(value, '_opfScheme') || Object.hasOwn(value, '_opfInherited')) yield value;
91
91
  for (const item of Object.values(value)) if (item !== null && typeof item === 'object') yield* markedRuns(item);
92
92
  }
93
93
 
@@ -96,13 +96,17 @@ function* markedRuns(value) {
96
96
  * {record, structure, content}: the stored OPF_SLIDE_V1 value, whether the slide's arrangement matches it and whether its
97
97
  * content structure was restored from it, or undefined for an untagged slide. `resolve(slide, name)` returns the '#RRGGBB'
98
98
  * the name resolves to in the document (or undefined). Every transient `_opfScheme` marker is removed.
99
+ * opf-pptx#212: a list run whose colour import removed as the list's own (OPF_SLIDE_V1 listRuns) carries `_opfInherited`, that
100
+ * colour: a stored reference that resolves to it is restored (an authored `text` role equals the inherited colour), and the
101
+ * marker is removed; a run left with only its text becomes a plain string again.
99
102
  */
100
103
  export function restoreRunColors(slides, info, resolve, report) {
101
104
  slides.forEach((slide, index) => {
102
105
  const tag = info[index];
103
106
  const marked = [...markedRuns(slide)];
104
107
  const restoreName = (run, name) => {
105
- const hex = typeof run.color === 'string' && /^#[0-9a-f]{6}$/i.test(run.color) ? run.color.toUpperCase() : undefined;
108
+ const current = run.color ?? run._opfInherited;
109
+ const hex = typeof current === 'string' && /^#[0-9a-f]{6}$/i.test(current) ? current.toUpperCase() : undefined;
106
110
  if (hex && resolve(slide, name)?.toUpperCase() === hex) run.color = name;
107
111
  };
108
112
  if (tag?.record?.colors) {
@@ -117,5 +121,27 @@ export function restoreRunColors(slides, info, resolve, report) {
117
121
  for (const run of marked) if (typeof run._opfScheme === 'string') restoreName(run, run._opfScheme);
118
122
  }
119
123
  for (const run of marked) delete run._opfScheme;
124
+ const inherited = new Set(marked.filter(run => Object.hasOwn(run, '_opfInherited')));
125
+ for (const run of inherited) delete run._opfInherited;
126
+ if (inherited.size) plainInheritedRuns(slide, inherited);
120
127
  });
121
128
  }
129
+
130
+ // The list values of a slide in which a run of `runs` is left with only its text: that run becomes a string, and a value that
131
+ // is then one string is that string (the authored form; the run slots are unchanged).
132
+ function plainInheritedRuns(slide, runs) {
133
+ const plain = value => {
134
+ if (!Array.isArray(value) || !value.some(run => runs.has(run))) return value;
135
+ const next = value.map(run => runs.has(run) && Object.keys(run).length === 1 ? run.text : run);
136
+ return next.length === 1 && typeof next[0] === 'string' ? next[0] : next;
137
+ };
138
+ for (const leaf of leaves(slide)) for (const field of ['items', 'bullets']) {
139
+ if (!Array.isArray(leaf[field])) continue;
140
+ leaf[field] = leaf[field].map(item => {
141
+ if (!object(item)) return plain(item);
142
+ const next = {...item, text: plain(item.text)};
143
+ if (item.description !== undefined) next.description = plain(item.description);
144
+ return next;
145
+ });
146
+ }
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@node-projects/jszip": "4.3.0",
68
- "@openpresentation/opf": "^0.18.1",
68
+ "@openpresentation/opf": "^0.18.3",
69
69
  "@rgrove/parse-xml": "4.2.3",
70
70
  "fast-xml-parser": "^5.8.0",
71
71
  "fflate": "^0.8.3",
@@ -73,7 +73,7 @@
73
73
  "sharp": "0.35.5"
74
74
  },
75
75
  "peerDependencies": {
76
- "@openpresentation/opf-render": "^0.18.0"
76
+ "@openpresentation/opf-render": "^0.18.1"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@openpresentation/opf-render": {
@@ -86,13 +86,18 @@
86
86
  "@expo-google-fonts/carlito": "0.4.1",
87
87
  "@expo-google-fonts/cousine": "0.4.3",
88
88
  "@expo-google-fonts/gelasio": "0.4.1",
89
+ "@expo-google-fonts/noto-color-emoji": "0.4.7",
89
90
  "@expo-google-fonts/noto-naskh-arabic": "0.4.5",
90
91
  "@expo-google-fonts/noto-sans": "0.4.2",
91
92
  "@expo-google-fonts/noto-sans-arabic": "0.4.3",
93
+ "@expo-google-fonts/noto-sans-armenian": "0.4.2",
94
+ "@expo-google-fonts/noto-sans-ethiopic": "0.4.2",
95
+ "@expo-google-fonts/noto-sans-georgian": "0.4.3",
96
+ "@expo-google-fonts/noto-sans-symbols-2": "0.4.1",
92
97
  "@expo-google-fonts/roboto": "0.4.3",
93
98
  "@expo-google-fonts/roboto-mono": "0.4.2",
94
99
  "@expo-google-fonts/tinos": "0.4.2",
95
- "@openpresentation/opf-render": "0.18.0",
100
+ "@openpresentation/opf-render": "0.18.1",
96
101
  "@resvg/resvg-js": "2.6.2",
97
102
  "esbuild": "0.28.2",
98
103
  "playwright": "1.63.0"