@openpresentation/opf-pptx 0.18.0 → 0.18.2
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 +26 -3
- package/dist/chart-order.js +76 -0
- package/dist/content-topology.js +25 -0
- package/dist/data-provenance.js +71 -4
- package/dist/document-provenance.js +27 -11
- package/dist/image-provenance.js +54 -0
- package/dist/index.js +127 -30
- package/dist/run-colors.js +28 -2
- package/package.json +9 -4
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
|
+
}
|
package/dist/content-topology.js
CHANGED
|
@@ -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
|
|
package/dist/data-provenance.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
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
|
|
918
|
-
*
|
|
919
|
-
*
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
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
|
|
package/dist/image-provenance.js
CHANGED
|
@@ -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,
|
|
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
|
|
819
|
-
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items,
|
|
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
|
-
|
|
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` (
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3134
|
-
//
|
|
3135
|
-
|
|
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, ...(
|
|
3224
|
+
diagnosticsHandled: true, align: "center", color: textColor, ...(named ? {objectName: `${objectName} text`} : {})
|
|
3170
3225
|
});
|
|
3171
|
-
return
|
|
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
|
|
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, ...(
|
|
3180
|
-
return
|
|
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={'&':'&','<':'<','>':'>','"':'"',"'":''','\r':' ','\n':' ','\t':'	'};
|
|
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
|
-
|
|
4230
|
-
|
|
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));
|
package/dist/run-colors.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.18.2",
|
|
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.
|
|
68
|
+
"@openpresentation/opf": "^0.18.4",
|
|
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.
|
|
76
|
+
"@openpresentation/opf-render": "^0.18.2"
|
|
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.
|
|
100
|
+
"@openpresentation/opf-render": "0.18.2",
|
|
96
101
|
"@resvg/resvg-js": "2.6.2",
|
|
97
102
|
"esbuild": "0.28.2",
|
|
98
103
|
"playwright": "1.63.0"
|