@openpresentation/opf-pptx 0.1.0 → 0.2.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
@@ -66,6 +66,8 @@ The first importer is mechanical and schema-compatible:
66
66
  - Slide text placeholders and large top-of-slide text boxes map to `title` and `subtitle` when recognizable.
67
67
  - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
68
68
  - PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to basic OPF chart blocks.
69
+ - 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.
70
+ - Imported table values are display strings: numeric/boolean/null types, rich cell formatting, whitespace and merged-cell semantics are not losslessly reconstructed.
69
71
  - Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
70
72
 
71
73
  There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
@@ -118,3 +120,71 @@ For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagin
118
120
  Pass the same `textMeasurement` provider used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks and resolved font family in editable PowerPoint shapes. Font binaries are not yet embedded in PPTX; native viewers still need the resolved font installed.
119
121
 
120
122
  PptxGenJS is pinned to 4.0.1. Its unused `image-size` dependency remains flagged by npm audit; tested OPF operations run with that parser blocked. See [dependency reachability and regression coverage](DEPENDENCY-NOTES.md).
123
+
124
+ ### Native table fitting (unreleased)
125
+
126
+ The development exporter measures every cell with the same `textMeasurement` provider, font roles and effective nested `minFontSize` used by the SVG preview. Native table cells retain the original strings and values as text, with matching fitted sizes, line spacing, alignment, margins and row/column geometry. Uneven rows receive empty cells for missing columns. Theme border colors now use the same slot as the preview.
127
+
128
+ `npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require shrinking, Roboto and Calibri-to-Carlito substitution, two canvas sizes, headers and all three alignments. PowerPoint still performs its own natural wrapping and needs the resolved fonts installed. These document-property checks do not establish native raster parity or lossless typed-cell import.
129
+
130
+ A local macOS Quick Look check opened both Roboto and system-Arial specimens. Quick Look substituted a serif font for uninstalled Roboto; the Arial specimen used a sans-serif face but still differed in table wrapping and row proportions. This is evidence of remaining viewer differences, not a passing PowerPoint raster comparison.
131
+
132
+ ## Image geometry (unreleased)
133
+
134
+ Native image exports now follow the browser's `design.imageFill`: `fit` (the default) centers an image without changing its aspect ratio, and `crop` fills the allocated box with a centered native crop. Slide settings override presentation settings. Geometry is calculated from the exact bytes embedded after asset resolution, so host resolvers are called once. PNG, JPEG, GIF and WebP dimension headers are supported; unsupported or unreadable dimensions produce a path-specific error rather than a distorted picture. Supply supported raster bytes through `imageResolver` for other formats.
135
+
136
+ JPEG EXIF orientations 1–8 are represented by native picture rotation and mirroring. The embedded copy's orientation tag is normalized to 1 to avoid viewer-dependent double rotation. Compressed pixels and other metadata remain unchanged; input data is not mutated. EXIF orientation in other containers, animated playback, SVG/vector assets, effects and lossless crop/orientation import are not covered by this change.
137
+
138
+ Tests compare SVG/native fit and crop geometry across nine synthetic raster fixtures and cover all eight JPEG orientations. Keynote 14.4 visually preserves proportions for wide/tall fit/crop and displays all eight orientations correctly. This does not establish Microsoft PowerPoint raster parity or WebP support in every Office version.
139
+
140
+ The structural export/import corpus gate covers every installed core example (126 decks / 805 slides for core 0.4.0). It explicitly substitutes a bundled fallback font and synthetic images, then checks slide XML, unique native object IDs, finite geometry, table grids and imported slide counts. It does not establish original-asset, typography or viewer fidelity. The focused table and image tests separately exercise measured geometry and real fixture bytes.
141
+
142
+ Raster media filenames and package content types are derived from the embedded PNG/JPEG/GIF/WebP bytes. A resolver may change the format without preserving an old asset MIME hint; import likewise detects these formats from their bytes. This metadata repair does not recompress images, validate every compressed pixel stream, fetch resources or establish viewer support for each format.
143
+
144
+ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens, but imports an unchanged WebP as an empty rectangle. The default compatible export now converts WebP to a static PNG locally. Keynote displays all six converted specimens, including alpha, EXIF orientation and the first animation frame. Microsoft PowerPoint has not been verified.
145
+
146
+ ### Compatible WebP pictures
147
+
148
+ `toPptx` defaults to `imageFormat: "compatible"`. After resolving and embedding an image once, WebP bytes are decoded to a static PNG. Alpha and EXIF orientation are retained in the decoded pixels; animated input uses its first frame. Fit/crop is then calculated from the resulting PNG dimensions. The original OPF input and source bytes are unchanged, but the PPTX contains the PNG rather than the original WebP or its metadata.
149
+
150
+ Set `imageFormat: "preserve"` to embed WebP unchanged when the receiving application supports it. Other image formats keep their existing export behavior. Conversion errors include the OPF image path; images above 40 megapixels are rejected before compatible conversion. Decoder differences can affect color/alpha rounding, so byte identity across platforms is not promised.
151
+
152
+ Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 20.9 or later. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
153
+
154
+ `npm test` includes the Node pixel-reference cases and verifies browser bundling. To run the browser pixel checks, run `npm run build:browser-check`, serve this repository locally, and open `/artifacts/webp-fallback/browser/index.html`. The page reports 13 checks covering embedded PNG pixels, alpha, EXIF, the first animation frame, fit/crop, resolver calls and DOM canvas fallback. These are browser export checks, separate from the recorded Keynote viewing evidence.
155
+
156
+
157
+ ## Native background fills (unreleased)
158
+
159
+ Fixed solid and linear-gradient backgrounds now export as native slide fills, keeping the background editable without rasterizing slide content. Deck defaults, inline theme overrides and per-slide overrides are resolved before export. Solid opacity, gradient stop colors/positions and combined color/background alpha are preserved. Empty and single-stop gradients follow the SVG preview's transparent/solid behavior; descending stop positions clamp to the preceding stop.
160
+
161
+ Diagonal gradients require a coordinate conversion: the preview uses an SVG object-bounding-box gradient, while native unscaled DrawingML angles use slide coordinates. Export converts both the physical gradient direction and stop interval. Tests compare 990 sample positions from serialized SVG/native properties across 33 gradients and three aspect ratios, plus solid opacity, inheritance, native edits and repeated imports/exports. Integer native angles/positions introduce small rounding differences. The mapping follows the [DrawingML linear-gradient angle definition](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.lineargradientfill?view=openxml-3.0.1).
162
+
163
+ Import reads supported native RGB solid/linear fills directly; it uses no hidden source copy. Uniform alpha becomes OPF background opacity, and differing stop alpha uses eight-bit RGBA colors (which can round alpha). Native path gradients, color transforms outside the supported luminance/alpha set, non-default tile/flip geometry and stop intervals outside OPF's fixed-endpoint representation are not imported. Pass `fromPptx(bytes, {onDiagnostic: issue => ...})` to observe `unsupported-background-gradient` with a slide path.
164
+
165
+ Node 20/24 tests and the 126-deck / 805-slide structural corpus pass. This proves serialization and the mathematical mapping, not native viewer pixels. Keynote 14.4 recognizes the editable native gradients. Twelve captured native PNGs now support 18 comparisons, including a Keynote-generated PPTX import: opaque differences are at most 4/255 per channel (mean below 0.38), and transparent portrait alpha differs by at most 1/255. The checked-in references run in ordinary Node tests without Keynote. Quick Look still renders these specimens as a flat average color, so its thumbnails are not evidence of their native appearance. Microsoft PowerPoint remains unavailable and unverified. Pattern/image backgrounds, theme-aware native fills, and other design decorations remain separate fidelity work.
166
+
167
+
168
+ ## JPEG orientation on import (unreleased)
169
+
170
+ `fromPptx` now preserves native quarter-turns and mirroring for JPEG pictures by writing the combined orientation into EXIF metadata. Existing embedded EXIF orientation is applied before the native transform. This requires no pixel decoder, recompression, upload or new dependency. The original PPTX remains unchanged, and alternative text survives. The eight orientations produced by this exporter restore the exact source JPEG bytes through repeated fit-mode export/import cycles.
171
+
172
+ When a JPEG has no orientation tag, import either adds a minimal EXIF segment or appends an IFD0 that retains the existing metadata entries, referenced data offsets and next-IFD link. Malformed or full EXIF segments are left untouched and reported. Tests cover both byte orders, embedded metadata plus native transformations, native picture edits, exact compressed-byte retention and independently permuted pixels.
173
+
174
+ This preserves image orientation, not arbitrary picture geometry. Crop windows, non-quarter-turn rotations, non-JPEG rotations/mirroring and unsupported EXIF structures retain their original image bytes and report `unsupported-image-crop` or `unsupported-image-orientation` through `FromPptxOptions.onDiagnostic`. Picture diagnostic paths identify native picture order, for example `slides.0.pictures.0`. Import still recomposes OPF layout and does not promise exact native placement, crop, effects, groups or full picture round-trip fidelity. Third-party native viewers may handle already-oriented embedded JPEGs differently; the metadata-before-native composition is the importer contract, not a cross-viewer parity claim.
175
+
176
+ ### Inherited native backgrounds (0.2.1)
177
+
178
+ Since 0.2.1, the importer follows slide → layout → master background inheritance. An explicit slide background, including no-fill or an unsupported fill, takes precedence over inherited content. Solid and representable linear fills resolve theme slots through the master color map and layout/slide overrides; system colors use the saved `lastClr` fallback. Theme overrides can replace the color scheme or format scheme. Background style references use the original XML order in `fillStyleLst`/`bgFillStyleLst`, including placeholder colors and alpha. Theme-slot OPF exports also retain background opacity.
179
+
180
+ The 0.2.1 importer applies `lum`, `lumMod`, `lumOff`, `alpha`, `alphaMod` and `alphaOff` in XML order, including repeated/interleaved transforms in theme definitions, style placeholders and gradient stops. Following DrawingML’s [luminance modulation](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.luminancemodulation?view=openxml-3.0.1) and [luminance offset](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.luminanceoffset?view=openxml-3.0.1) semantics, luminance adjustments retain hue and saturation, and opacity operations clamp after each step. Colors are rounded to RGB only after the full reference/transform chain. Tint/shade, saturation/hue, gamma and other transforms still produce diagnostics. The regression suite covers 70 inheritance, transform and diagnostic cases; these mathematical tests do not establish native viewer pixel parity.
181
+
182
+ These colors become explicit editable OPF RGB fills. Import does not preserve a live link to the original PowerPoint master/theme. No external theme URL is fetched. Missing themes, unknown colors, unsupported color transforms, and unsupported image/pattern fills report `unsupported-background-fill` (or `unsupported-background-gradient` for gradients) at the slide background path.
183
+
184
+ The regression fixtures cover inheritance, overrides, interleaved style lists, repeated export/import, source preservation and observable failures. The style indexes follow the [Open XML background-reference definition](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.backgroundstylereference?view=openxml-3.0.1). They prove conversion semantics, not universal native appearance. Keynote displayed a fixture referencing `fillStyleLst` index 2 as white; native comparison of other reference forms is still incomplete. Microsoft PowerPoint remains unverified. This work is not included in npm 0.2.0.
185
+
186
+ Native dimensions retain full precision through import. Premature six-decimal inch rounding could change raster edges even on a 1280-pixel slide. With the local JPEG-aware renderer, all eight complete image-slide PNG previews now match their original OPF previews after native export/import; this remains an OPF-renderer comparison, not a native viewer pixel comparison.
187
+
188
+ Background-only and empty slides now remain blank during PPTX import; the importer no longer inserts a synthetic “Slide N” title. Speaker notes remain separate from visible content. Native fixture verification covers this behavior using an actual Keynote-exported presentation.
189
+
190
+ Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
@@ -0,0 +1,88 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {readNativeBackground, readBackgroundColor, colorTransforms} from './background.js';
3
+
4
+ const orderedParser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', preserveOrder: true, parseAttributeValue: false, parseTagValue: false});
5
+ const defaultMapping = {bg1:'lt1',tx1:'dk1',bg2:'lt2',tx2:'dk2'};
6
+ const children = (nodes, name) => nodes?.find(node => Object.hasOwn(node, name))?.[name];
7
+ function object(nodes) {
8
+ const result = Object.create(null);
9
+ for (const node of nodes ?? []) for (const [name, value] of Object.entries(node)) {
10
+ if (name === ':@' || name === '#text') continue;
11
+ const child = {...(node[':@'] ?? {}), ...object(value)};
12
+ if (['a:srgbClr', 'a:sysClr', 'a:schemeClr'].includes(name)) {
13
+ child[colorTransforms] = (value ?? []).flatMap(item => Object.keys(item)
14
+ .filter(key => key !== ':@' && key !== '#text')
15
+ .map(key => [key, {...(item[':@'] ?? {}), ...object(item[key])}]));
16
+ }
17
+ if (Object.hasOwn(result, name)) result[name] = Array.isArray(result[name]) ? [...result[name], child] : [result[name], child];
18
+ else result[name] = child;
19
+ }
20
+ return result;
21
+ }
22
+
23
+ // Cache by archive entry identity. Weak keys release parsed themes when the
24
+ // input archive is collected and avoid reparsing a shared master for every slide.
25
+ const parsedParts = new WeakMap();
26
+ function parsed(bytes, parse) {
27
+ if (!bytes) return undefined;
28
+ if (!parsedParts.has(bytes)) {
29
+ const tree = parse();
30
+ parsedParts.set(bytes, {tree, value: object(tree)});
31
+ }
32
+ return parsedParts.get(bytes);
33
+ }
34
+
35
+ // Resolve only relationships inside the input archive; never fetch theme URLs.
36
+ export function importBackground(slidePath, dimensions, {part, relationships, bytes}, report) {
37
+ const parsedPart = path => parsed(bytes(path), () => part(path, orderedParser));
38
+ const value = path => parsedPart(path)?.value;
39
+ const related = (path, type) => [...relationships(path).values()].find(rel => rel.type.endsWith('/' + type) && bytes(rel.path))?.path;
40
+ const layoutPath = related(slidePath, 'slideLayout');
41
+ const masterPath = layoutPath && related(layoutPath, 'slideMaster');
42
+ const chain = [[slidePath, 'p:sld'], [layoutPath, 'p:sldLayout'], [masterPath, 'p:sldMaster']]
43
+ .filter(([path]) => path).map(([path, root]) => ({path, root:value(path)?.[root]}));
44
+ const master = chain.find(item => item.root?.['p:clrMap'])?.root;
45
+ const masterMapping = {...defaultMapping, ...master?.['p:clrMap']};
46
+ let mapping = masterMapping;
47
+ for (const {root} of [...chain].reverse()) {
48
+ const override = root?.['p:clrMapOvr'];
49
+ if (override?.['a:overrideClrMapping']) mapping = {...defaultMapping, ...override['a:overrideClrMapping']};
50
+ else if (override && Object.hasOwn(override, 'a:masterClrMapping')) mapping = masterMapping;
51
+ }
52
+ let colors, format, formatPath;
53
+ for (const {path} of [...chain].reverse()) {
54
+ for (const type of ['theme', 'themeOverride']) {
55
+ const themePath = related(path, type);
56
+ if (!themePath) continue;
57
+ const doc = value(themePath);
58
+ const elements = type === 'theme' ? doc?.['a:theme']?.['a:themeElements'] : doc?.['a:themeOverride'];
59
+ if (elements?.['a:clrScheme']) colors = elements['a:clrScheme'];
60
+ if (elements?.['a:fmtScheme']) {format = elements['a:fmtScheme'];formatPath = themePath;}
61
+ }
62
+ }
63
+ const background = chain.map(item => item.root?.['p:cSld']?.['p:bg']).find(value => value !== undefined);
64
+ if (!background) return undefined;
65
+ const unsupported = () => {
66
+ report({code:'unsupported-background-fill',message:'The native background style reference or its theme color could not be resolved from this PPTX archive.'});
67
+ return undefined;
68
+ };
69
+ const context = {colors, mapping};
70
+ if (background['p:bgPr']) return readNativeBackground(background['p:bgPr'], dimensions, report, context);
71
+ const reference = background['p:bgRef'];
72
+ if (!/^\d+$/.test(reference?.idx ?? '')) return unsupported();
73
+ const index = Number(reference?.idx);
74
+ if (!Number.isInteger(index) || index < 0) return unsupported();
75
+ if (index === 0 || index === 1000) return {type:'solid',color:'#FFFFFF',opacity:0};
76
+ if (!format || !formatPath) return unsupported();
77
+ // Fill lists can interleave solid, gradient and image fills. Preserve XML
78
+ // child order when indexing them; the normal object parser groups tag names.
79
+ const tree = parsedPart(formatPath).tree;
80
+ const theme = children(tree, 'a:theme');
81
+ const elements = theme ? children(theme, 'a:themeElements') : children(tree, 'a:themeOverride');
82
+ const fmt = children(elements, 'a:fmtScheme');
83
+ const styles = children(fmt, index < 1000 ? 'a:fillStyleLst' : 'a:bgFillStyleLst')?.filter(node => Object.keys(node).some(k => k !== '#text' && k !== ':@'));
84
+ const style = styles?.[index < 1000 ? index - 1 : index - 1001];
85
+ if (!style) return unsupported();
86
+ context.placeholder = readBackgroundColor(reference, context);
87
+ return readNativeBackground(object([style]), dimensions, report, context);
88
+ }
@@ -0,0 +1,136 @@
1
+ // SVG uses an object-bounding-box gradient. DrawingML's unscaled angle is
2
+ // measured in slide coordinates. Convert the normal and stop interval together;
3
+ // copying the angle alone changes diagonal gradients on non-square slides.
4
+ const turn = angle => ((angle % 360) + 360) % 360;
5
+ const clamp = value => Math.max(0, Math.min(1, value));
6
+ const list = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
7
+ function color(value, fallback = 'FFFFFF') {
8
+ if (!/^[\da-f]{6}$/i.test(fallback)) fallback = 'FFFFFF';
9
+ let hex = typeof value === 'string' ? value.trim().replace(/^#/, '') : fallback;
10
+ if (/^[\da-f]{3}$/i.test(hex)) hex = [...hex].map(c => c + c).join('');
11
+ if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(hex)) hex = fallback;
12
+ return {hex: hex.slice(0, 6).toUpperCase(), alpha: hex.length === 8 ? parseInt(hex.slice(6), 16) / 255 : 1};
13
+ }
14
+ function colorXml(value, opacity, fallback) {
15
+ const c = color(value, fallback), alpha = Math.round(c.alpha * opacity * 100000);
16
+ return `<a:srgbClr val="${c.hex}">${alpha === 100000 ? '' : `<a:alpha val="${alpha}"/>`}</a:srgbClr>`;
17
+ }
18
+
19
+ export function nativeBackgroundFill(background, {width, height}, fallback = 'FFFFFF') {
20
+ if (typeof background === 'string' && /^#[\da-f]{3}(?:[\da-f]{3}(?:[\da-f]{2})?)?$/i.test(background)) background = {type: 'solid', color: background};
21
+ if (!background || typeof background !== 'object') return null;
22
+ const opacity = background.opacity ?? 1;
23
+ if (background.type === 'solid' || background.type === 'theme') return `<a:solidFill>${colorXml(background.type === 'theme' ? fallback : background.color, opacity, fallback)}</a:solidFill>`;
24
+ if (background.type !== 'gradient') return null;
25
+ const stops = background.gradient?.stops ?? [];
26
+ if (!stops.length) return '<a:noFill/>';
27
+ if (stops.length === 1) return `<a:solidFill>${colorXml(stops[0].color, opacity, fallback)}</a:solidFill>`;
28
+ const radians = turn(background.gradient?.angle ?? 0) * Math.PI / 180;
29
+ const c = Math.cos(radians), s = Math.sin(radians), span = Math.abs(c) + Math.abs(s);
30
+ const angle = Math.round(turn(Math.atan2(s / height, c / width) * 180 / Math.PI) * 60000) % 21600000;
31
+ let prior = 0;
32
+ const nativeStops = stops.map(stop => {
33
+ // SVG clamps a descending stop to the preceding position.
34
+ prior = Math.max(prior, stop.position);
35
+ const position = Math.round(((prior - .5) / span + .5) * 100000);
36
+ return `<a:gs pos="${position}">${colorXml(stop.color, opacity, fallback)}</a:gs>`;
37
+ }).join('');
38
+ return `<a:gradFill rotWithShape="0"><a:gsLst>${nativeStops}</a:gsLst><a:lin ang="${angle}" scaled="0"/></a:gradFill>`;
39
+ }
40
+
41
+ // Internal metadata supplied by the ordered XML reader. A Symbol cannot collide
42
+ // with a document attribute, and repeated transforms keep their original order.
43
+ export const colorTransforms = Symbol('DrawingML color transforms');
44
+ const transformNames = new Set(['a:alpha', 'a:alphaMod', 'a:alphaOff', 'a:lum', 'a:lumMod', 'a:lumOff']);
45
+ function luminanceColor(hex) {
46
+ const rgb = hex.match(/../g).map(value => parseInt(value, 16) / 255);
47
+ const max = Math.max(...rgb), min = Math.min(...rgb), l = (max + min) / 2, delta = max - min;
48
+ // Retain hue/saturation across consecutive luminance changes, even when an
49
+ // intermediate luminance clips to black or white.
50
+ return {l, saturation: delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)), direction: rgb.map(c => delta ? (c - l) / delta : 0)};
51
+ }
52
+ function luminanceHex({l, saturation, direction}) {
53
+ const chroma = (1 - Math.abs(2 * l - 1)) * saturation;
54
+ return '#' + direction.map(c => Math.round(clamp(l + c * chroma) * 255).toString(16).padStart(2, '0')).join('').toUpperCase();
55
+ }
56
+ export function readBackgroundColor(node, context = {}, seen = new Set()) {
57
+ const kinds = ['a:srgbClr', 'a:sysClr', 'a:schemeClr'].filter(k => node?.[k]);
58
+ if (kinds.length !== 1) return null;
59
+ const kind = kinds[0], c = node[kind];
60
+ if (Array.isArray(c) || Object.keys(c).some(k => !['val', 'lastClr'].includes(k) && !transformNames.has(k))) return null;
61
+ const entries = Object.entries(c).filter(([key]) => key.startsWith('a:'));
62
+ // The legacy object shape is safe for one transform only. Never guess the
63
+ // order of repeated/interleaved operations after an unordered parser.
64
+ const transforms = c[colorTransforms] ?? (entries.length <= 1 && entries.every(([,value]) => !Array.isArray(value)) ? entries : null);
65
+ if (!transforms) return null;
66
+ let resolved;
67
+ if (kind === 'a:schemeClr') {
68
+ if (c.val === 'phClr') resolved = context.placeholder;
69
+ else {
70
+ const slot = context.mapping?.[c.val] ?? c.val;
71
+ if (seen.has(slot)) return null;
72
+ resolved = readBackgroundColor(context.colors?.['a:' + slot], context, new Set([...seen, slot]));
73
+ }
74
+ } else {
75
+ const hex = kind === 'a:sysClr' ? c.lastClr : c.val;
76
+ if (!/^[\da-f]{6}$/i.test(hex ?? '')) return null;
77
+ resolved = {hex: '#' + hex.toUpperCase(), alpha: 1, luminance: luminanceColor(hex)};
78
+ }
79
+ if (!resolved) return null;
80
+ let alpha = resolved.alpha;
81
+ const luminance = {...(resolved.luminance ?? luminanceColor(resolved.hex.slice(1)))};
82
+ for (const [name, attributes] of transforms) {
83
+ if (!transformNames.has(name) || !attributes || Object.keys(attributes).some(key => key !== 'val')) return null;
84
+ const raw = attributes.val;
85
+ if (!/^[+-]?\d+$/.test(raw ?? '')) return null;
86
+ const value = Number(raw) / 100000;
87
+ if (!Number.isFinite(value)) return null;
88
+ if ((name === 'a:alpha' || name === 'a:lum') && (value < 0 || value > 1)) return null;
89
+ if (name === 'a:alphaMod' && value < 0) return null;
90
+ if (name === 'a:alphaOff' && Math.abs(value) > 1) return null;
91
+ if (name === 'a:alpha') alpha = value;
92
+ if (name === 'a:alphaMod') alpha = clamp(alpha * value);
93
+ if (name === 'a:alphaOff') alpha = clamp(alpha + value);
94
+ if (name === 'a:lum') luminance.l = value;
95
+ if (name === 'a:lumMod') luminance.l = clamp(luminance.l * value);
96
+ if (name === 'a:lumOff') luminance.l = clamp(luminance.l + value);
97
+ }
98
+ return {hex: luminanceHex(luminance), alpha, luminance};
99
+ }
100
+
101
+ export function readNativeBackground(properties, {width, height}, report = () => {}, context = {}) {
102
+ if (!properties) return undefined;
103
+ if (Object.hasOwn(properties, 'a:noFill')) return {type: 'solid', color: '#FFFFFF', opacity: 0};
104
+ const solid = readBackgroundColor(properties['a:solidFill'], context);
105
+ if (solid) return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
106
+ const gradient = properties['a:gradFill'];
107
+ if (!gradient) {
108
+ report({code: 'unsupported-background-fill', message: 'This native background fill or color cannot be represented by the OPF background importer.'});
109
+ return undefined;
110
+ }
111
+ const unsupported = () => {
112
+ report({code: 'unsupported-background-gradient', message: 'This native gradient uses geometry or color transforms outside the OPF linear-gradient contract; its background was not imported.'});
113
+ return undefined;
114
+ };
115
+ const lin = gradient['a:lin'];
116
+ if (!lin || gradient['a:path'] || (gradient.flip && gradient.flip !== 'none')
117
+ || Object.values(gradient['a:tileRect'] ?? {}).some(value => Number(value) !== 0)) return unsupported();
118
+ let radians = Number(lin.ang ?? 0) / 60000 * Math.PI / 180;
119
+ if (!Number.isFinite(radians)) return unsupported();
120
+ // DrawingML scaled=true first scales the direction by the fill dimensions.
121
+ if (lin.scaled === '1' || lin.scaled === 'true') radians = Math.atan2(height * Math.sin(radians), width * Math.cos(radians));
122
+ const angle = turn(Math.atan2(height * Math.sin(radians), width * Math.cos(radians)) * 180 / Math.PI);
123
+ const a = angle * Math.PI / 180, span = Math.abs(Math.cos(a)) + Math.abs(Math.sin(a));
124
+ const stops = list(gradient['a:gsLst']?.['a:gs']).map(stop => {
125
+ const c = readBackgroundColor(stop, context), position = (Number(stop.pos) / 100000 - .5) * span + .5;
126
+ // Allow only native integer rounding, not a lossy clamping of arbitrary
127
+ // corner-to-corner gradients that OPF's fixed endpoints cannot represent.
128
+ if (!c || !Number.isFinite(position) || position < -.00002 || position > 1.00002) return null;
129
+ return {...c, position: clamp(position)};
130
+ });
131
+ if (!stops.length || stops.some(stop => !stop)) return unsupported();
132
+ const alpha = stops[0].alpha, uniform = stops.every(stop => stop.alpha === alpha);
133
+ return {type: 'gradient', gradient: {angle, stops: stops.map(stop => ({position: stop.position,
134
+ color: stop.hex + (!uniform && stop.alpha !== 1 ? Math.round(stop.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '')}))},
135
+ ...(uniform && alpha !== 1 ? {opacity: alpha} : {})};
136
+ }
@@ -0,0 +1,33 @@
1
+ // Decode only the supplied local bytes. No URL is fetched and no canvas is
2
+ // attached to the document. Animated WebP becomes its first decoded frame.
3
+ export async function webpToPng(bytes) {
4
+ const blob = new Blob([bytes], {type:'image/webp'});
5
+ let picture, objectUrl;
6
+ try {
7
+ if (typeof createImageBitmap === 'function') {
8
+ picture = await createImageBitmap(blob);
9
+ } else {
10
+ if (typeof Image === 'undefined') throw new Error('This browser has no image decoder.');
11
+ objectUrl = URL.createObjectURL(blob);
12
+ picture = new Image();
13
+ picture.src = objectUrl;
14
+ await picture.decode();
15
+ }
16
+ const width = picture.width || picture.naturalWidth;
17
+ const height = picture.height || picture.naturalHeight;
18
+ if (!width || !height || width * height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
19
+ const canvas = typeof OffscreenCanvas === 'function'
20
+ ? new OffscreenCanvas(width, height) : document.createElement('canvas');
21
+ canvas.width = width; canvas.height = height;
22
+ const context = canvas.getContext('2d');
23
+ if (!context) throw new Error('This browser has no 2D canvas.');
24
+ context.drawImage(picture, 0, 0);
25
+ const png = canvas.convertToBlob
26
+ ? await canvas.convertToBlob({type:'image/png'})
27
+ : await new Promise((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed.')), 'image/png'));
28
+ return new Uint8Array(await png.arrayBuffer());
29
+ } finally {
30
+ picture?.close?.();
31
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
32
+ }
33
+ }
@@ -0,0 +1,8 @@
1
+ // Loaded only when a WebP needs a compatible static picture. Sharp receives
2
+ // bytes, never URLs or paths, and does not fetch external resources.
3
+ export async function webpToPng(bytes) {
4
+ const { default: sharp } = await import('sharp');
5
+ return new Uint8Array(await sharp(bytes, {limitInputPixels: 40_000_000, animated: false, failOn: 'warning'})
6
+ .autoOrient().toColourspace('srgb').ensureAlpha()
7
+ .png({compressionLevel: 9, adaptiveFiltering: false}).toBuffer());
8
+ }
@@ -0,0 +1,113 @@
1
+ // Read dimensions only; never decode pixels or follow resource references.
2
+ // PNG: https://www.w3.org/TR/PNG-Chunks.html
3
+ // WebP: https://developers.google.com/speed/webp/docs/riff_container
4
+ export function rasterDimensions(bytes) {
5
+ const info = rasterMetadata(bytes);
6
+ return info ? { width: info.width, height: info.height } : null;
7
+ }
8
+
9
+ export function rasterMetadata(bytes) {
10
+ if (!(bytes instanceof Uint8Array)) return null;
11
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
12
+ const text = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
13
+ const size = (width, height, mediaType) => Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 ? { width, height, mediaType } : null;
14
+ if (bytes.length >= 33 && text(0, 8) === '\x89PNG\r\n\x1a\n' && view.getUint32(8) === 13 && text(12, 4) === 'IHDR') {
15
+ return size(view.getUint32(16), view.getUint32(20), "image/png");
16
+ }
17
+ if (bytes.length >= 13 && ['GIF87a', 'GIF89a'].includes(text(0, 6))) {
18
+ return size(view.getUint16(6, true), view.getUint16(8, true), "image/gif");
19
+ }
20
+ if (bytes.length >= 12 && text(0, 4) === 'RIFF' && text(8, 4) === 'WEBP') {
21
+ const end = view.getUint32(4, true) + 8;
22
+ if (end > bytes.length) return null;
23
+ for (let at = 12; at + 8 <= end;) {
24
+ const kind = text(at, 4), length = view.getUint32(at + 4, true), start = at + 8;
25
+ if (start + length > end) return null;
26
+ if (kind === 'VP8X' && length >= 10) {
27
+ const u24 = offset => bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
28
+ return size(u24(start + 4) + 1, u24(start + 7) + 1, "image/webp");
29
+ }
30
+ if (kind === 'VP8L' && length >= 5 && bytes[start] === 0x2f) {
31
+ const bits = view.getUint32(start + 1, true);
32
+ return size((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1, "image/webp");
33
+ }
34
+ if (kind === 'VP8 ' && length >= 10 && text(start + 3, 3) === '\x9d\x01\x2a') {
35
+ return size(view.getUint16(start + 6, true) & 0x3fff, view.getUint16(start + 8, true) & 0x3fff, "image/webp");
36
+ }
37
+ at = start + length + (length & 1);
38
+ }
39
+ return null;
40
+ }
41
+ if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
42
+ let dimensions = null, orientation = null;
43
+ // JPEG segment lengths include their two length bytes. Every iteration
44
+ // advances within the input, including fill bytes and standalone markers.
45
+ for (let at = 2; at < bytes.length;) {
46
+ if (bytes[at++] !== 0xff) return null;
47
+ while (at < bytes.length && bytes[at] === 0xff) at++;
48
+ if (at >= bytes.length) return null;
49
+ const marker = bytes[at++];
50
+ if (marker === 0xda || marker === 0xd9) return dimensions ? { ...dimensions, ...orientation } : null;
51
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
52
+ if (at + 2 > bytes.length) return null;
53
+ const length = view.getUint16(at);
54
+ if (length < 2 || at + length > bytes.length) return null;
55
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
56
+ dimensions = length >= 8 ? size(view.getUint16(at + 5), view.getUint16(at + 3), "image/jpeg") : null;
57
+ }
58
+ if (marker === 0xe1 && text(at + 2, 6) === 'Exif\x00\x00') orientation ??= exifOrientation(bytes, at + 8, at + length);
59
+ at += length;
60
+ }
61
+ return dimensions ? { ...dimensions, ...orientation } : null;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Read only IFD0's inline SHORT orientation. All offsets and entry counts
67
+ // stay within the APP1 segment; no linked IFDs or external values are followed.
68
+ function exifOrientation(bytes, start, end) {
69
+ if (start + 8 > end) return null;
70
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
71
+ const littleEndian = bytes[start] === 0x49 && bytes[start + 1] === 0x49;
72
+ if (!littleEndian && !(bytes[start] === 0x4d && bytes[start + 1] === 0x4d)) return null;
73
+ if (view.getUint16(start + 2, littleEndian) !== 42) return null;
74
+ const directory = start + view.getUint32(start + 4, littleEndian);
75
+ if (directory < start + 8 || directory + 2 > end) return null;
76
+ const count = view.getUint16(directory, littleEndian);
77
+ if (directory + 2 + count * 12 > end) return null;
78
+ for (let i = 0; i < count; i++) {
79
+ const at = directory + 2 + i * 12;
80
+ if (view.getUint16(at, littleEndian) !== 0x112 || view.getUint16(at + 2, littleEndian) !== 3 || view.getUint32(at + 4, littleEndian) !== 1) continue;
81
+ const orientation = view.getUint16(at + 8, littleEndian);
82
+ if (orientation >= 1 && orientation <= 8) return { orientation, orientationOffset: at + 8, littleEndian };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function fitImageBox(image, box, mode) {
88
+ const scale = (mode === 'crop' ? Math.max : Math.min)(box.w / image.width, box.h / image.height);
89
+ const width = image.width * scale, height = image.height * scale;
90
+ if (mode !== 'crop') return { x: box.x + (box.w - width) / 2, y: box.y + (box.h - height) / 2, w: width, h: height, crop: null };
91
+ const horizontal = Math.max(0, Math.round((1 - box.w / width) * 50000));
92
+ const vertical = Math.max(0, Math.round((1 - box.h / height) * 50000));
93
+ return { ...box, crop: { l: horizontal, r: horizontal, t: vertical, b: vertical } };
94
+ }
95
+
96
+ export function pictureTransform(metadata, box, mode) {
97
+ const orientation = metadata.orientation ?? 1;
98
+ const swapsAxes = orientation >= 5;
99
+ const target = swapsAxes
100
+ ? { x: box.x + (box.w - box.h) / 2, y: box.y + (box.h - box.w) / 2, w: box.h, h: box.w }
101
+ : box;
102
+ const fitted = fitImageBox(metadata, target, mode);
103
+ // DrawingML flips the source axes before applying clockwise rotation.
104
+ const rotation = [0, 0, 0, 180, 0, 90, 90, 90, 270][orientation];
105
+ return { ...fitted, rotation, flipH: orientation === 2 || orientation === 7, flipV: orientation === 4 || orientation === 5 };
106
+ }
107
+
108
+ export function normalizeImageOrientation(bytes, metadata) {
109
+ if (!metadata?.orientationOffset || metadata.orientation === 1) return bytes;
110
+ const output = new Uint8Array(bytes);
111
+ new DataView(output.buffer, output.byteOffset, output.byteLength).setUint16(metadata.orientationOffset, 1, metadata.littleEndian);
112
+ return output;
113
+ }
@@ -0,0 +1,92 @@
1
+ import {rasterMetadata} from './image-geometry.js';
2
+
3
+ // Centered, normalized source coordinates: [a,b,c,d] maps (x,y) to
4
+ // (a*x+b*y,c*x+d*y). These are the eight JPEG EXIF orientation transforms.
5
+ const orientations = [null, [1,0,0,1], [-1,0,0,1], [-1,0,0,-1], [1,0,0,-1],
6
+ [0,1,1,0], [0,-1,1,0], [0,-1,-1,0], [0,1,-1,0]];
7
+ const multiply = ([a,b,c,d], [e,f,g,h]) => [a*e+b*g,a*f+b*h,c*e+d*g,c*f+d*h];
8
+ const truth = value => value === '1' || value === 'true';
9
+
10
+ export function importImageOrientation(bytes, transform, report = () => {}) {
11
+ const rotation = Number(transform?.rot ?? 0) / 60000;
12
+ const flipH = truth(transform?.flipH), flipV = truth(transform?.flipV);
13
+ if (Number.isFinite(rotation) && rotation % 360 === 0 && !flipH && !flipV) return bytes;
14
+ const unsupported = message => {report({code:'unsupported-image-orientation', message});return bytes;};
15
+ if (!Number.isFinite(rotation) || rotation % 90 !== 0) {
16
+ return unsupported('The native picture rotation/mirroring cannot be represented by JPEG EXIF metadata; its original image bytes were retained.');
17
+ }
18
+ const quarter = ((rotation / 90) % 4 + 4) % 4;
19
+ const rotations = [orientations[1],orientations[6],orientations[3],orientations[8]];
20
+ const native = multiply(rotations[quarter], [flipH ? -1 : 1,0,0,flipV ? -1 : 1]);
21
+ if (native.every((value,i) => value === orientations[1][i])) return bytes;
22
+ const metadata = rasterMetadata(bytes);
23
+ if (!metadata || metadata.mediaType !== 'image/jpeg') {
24
+ return unsupported('Native rotation/mirroring of this image format is not preserved by OPF import; its original image bytes were retained.');
25
+ }
26
+ const combined = multiply(native, orientations[metadata.orientation ?? 1]);
27
+ const orientation = orientations.findIndex(matrix => matrix && matrix.every((n,i) => n === combined[i]));
28
+ try { return withJpegOrientation(bytes, metadata, orientation); }
29
+ catch { return unsupported('The JPEG EXIF segment cannot safely accommodate the native picture orientation; its original image bytes were retained.'); }
30
+ }
31
+
32
+ function withJpegOrientation(bytes, metadata, orientation) {
33
+ if (orientation === (metadata.orientation ?? 1)) return bytes;
34
+ if (metadata.orientationOffset !== undefined) {
35
+ const output = new Uint8Array(bytes);
36
+ new DataView(output.buffer, output.byteOffset, output.byteLength).setUint16(metadata.orientationOffset, orientation, metadata.littleEndian);
37
+ return output;
38
+ }
39
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
40
+ // An existing EXIF block without orientation keeps all relative data/IFD
41
+ // offsets. Append a replacement IFD0, preserving its entries and next-IFD
42
+ // link, then point its TIFF header to the new directory.
43
+ for (let at = 2; at + 4 <= bytes.length;) {
44
+ if (bytes[at++] !== 0xff) throw new Error('Invalid JPEG marker');
45
+ while (bytes[at] === 0xff) at++;
46
+ const marker = bytes[at++];
47
+ if (marker === 0xda || marker === 0xd9) break;
48
+ if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
49
+ const length = view.getUint16(at), end = at + length;
50
+ if (length < 2 || end > bytes.length) throw new Error('Invalid JPEG segment');
51
+ if (marker === 0xe1 && length >= 16 && String.fromCharCode(...bytes.subarray(at+2,at+8)) === 'Exif\0\0') {
52
+ const start = at + 8, little = bytes[start] === 0x49 && bytes[start+1] === 0x49;
53
+ if ((!little && !(bytes[start] === 0x4d && bytes[start+1] === 0x4d)) || view.getUint16(start+2,little) !== 42) throw new Error('Invalid TIFF');
54
+ const directory = start + view.getUint32(start+4,little);
55
+ if (directory < start+8 || directory+2 > end) throw new Error('Invalid IFD');
56
+ const count = view.getUint16(directory,little), tail = directory + 2 + count*12;
57
+ if (tail+4 > end || count === 65535) throw new Error('Invalid IFD entries');
58
+ // A malformed orientation entry cannot be duplicated into a valid one.
59
+ for (let i=0;i<count;i++) if (view.getUint16(directory+2+i*12,little) === 0x112) throw new Error('Malformed orientation entry');
60
+ const padding = (end-start) % 2, extra = padding + 2 + (count+1)*12 + 4;
61
+ if (length+extra > 65535) throw new Error('EXIF segment too large');
62
+ const output = new Uint8Array(bytes.length+extra);
63
+ output.set(bytes.subarray(0,end));output.set(bytes.subarray(end),end+extra);
64
+ const out = new DataView(output.buffer), replacement = end+padding;
65
+ out.setUint16(at,length+extra);out.setUint32(start+4,replacement-start,little);
66
+ out.setUint16(replacement,count+1,little);
67
+ let target=replacement+2, inserted=false;
68
+ for (let i=0;i<count;i++) {
69
+ const source=directory+2+i*12;
70
+ if (!inserted && view.getUint16(source,little)>0x112) {writeEntry(out,target,orientation,little);target+=12;inserted=true;}
71
+ output.set(bytes.subarray(source,source+12),target);target+=12;
72
+ }
73
+ if (!inserted) {writeEntry(out,target,orientation,little);target+=12;}
74
+ output.set(bytes.subarray(tail,tail+4),target);
75
+ return output;
76
+ }
77
+ at=end;
78
+ }
79
+ // Plain JPEG: insert a minimal independent APP1/IFD0 after SOI. Every
80
+ // original byte after SOI is retained, including compressed scan data.
81
+ const segment = new Uint8Array(36), out = new DataView(segment.buffer);
82
+ segment.set([0xff,0xe1,0,34,0x45,0x78,0x69,0x66,0,0,0x4d,0x4d,0,42,0,0,0,8,0,1]);
83
+ writeEntry(out,20,orientation,false);
84
+ const output = new Uint8Array(bytes.length+segment.length);
85
+ output.set(bytes.subarray(0,2));output.set(segment,2);output.set(bytes.subarray(2),2+segment.length);
86
+ return output;
87
+ }
88
+
89
+ function writeEntry(view, at, orientation, little) {
90
+ view.setUint16(at,0x112,little);view.setUint16(at+2,3,little);
91
+ view.setUint32(at+4,1,little);view.setUint16(at+8,orientation,little);
92
+ }
package/dist/index.d.ts CHANGED
@@ -34,6 +34,8 @@ export interface ImageResolverContext {
34
34
  }
35
35
 
36
36
  export interface ToPptxOptions {
37
+ /** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
38
+ imageFormat?: "compatible" | "preserve";
37
39
  textMeasurement?: TextMeasurement;
38
40
  onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
39
41
  baseDir?: string;
@@ -46,6 +48,8 @@ export interface ToPptxOptions {
46
48
  }
47
49
 
48
50
  export interface FromPptxOptions {
51
+ /** Reports native gradient or picture crop/transform details that OPF import cannot preserve. Picture paths identify the native picture index. */
52
+ onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
49
53
  fallbackName?: string;
50
54
  schema?: string;
51
55
  }
package/dist/index.js CHANGED
@@ -1,4 +1,9 @@
1
- import { composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
1
+ import {importImageOrientation} from './image-import.js';
2
+ import {nativeBackgroundFill} from './background.js';
3
+ import {importBackground} from './background-import.js';
4
+ import { webpToPng } from '#image-fallback';
5
+ import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
6
+ import { composeSlide, fitText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
2
7
  import PptxGenJS from "pptxgenjs";
3
8
  import { unzipSync, zipSync } from "fflate";
4
9
  import { XMLParser } from "fast-xml-parser";
@@ -152,11 +157,18 @@ const CHART_COLORS = [
152
157
  ];
153
158
 
154
159
  export async function toPptx(input, options = {}) {
160
+ if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
161
+ throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
162
+ }
155
163
  const presentation = parseInput(input);
156
164
  assertValidBoundary(presentation);
157
165
 
158
166
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
159
167
  context.listMarkers = new Map();
168
+ context.tableHeaders = new Map();
169
+ context.imagePlacements = new Map();
170
+ context.backgroundFills = new Map();
171
+ context.imageFormat = options.imageFormat ?? "compatible";
160
172
  const pptx = new PptxGenJS();
161
173
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
162
174
 
@@ -206,7 +218,7 @@ export async function fromPptx(input, options = {}) {
206
218
  if (dimensions) imported.design = { dimensions };
207
219
 
208
220
  for (let index = 0; index < slidePaths.length; index += 1) {
209
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions));
221
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
210
222
  }
211
223
 
212
224
  const result = validatePresentation(imported);
@@ -239,13 +251,13 @@ function readPptxZip(input) {
239
251
  }
240
252
  }
241
253
 
242
- function parseRequiredXml(entries, path) {
254
+ function parseRequiredXml(entries, path, parser = xmlParser) {
243
255
  const bytes = entries[path];
244
256
  if (!bytes) {
245
257
  throw new OPFPptxError("invalid-pptx", `PPTX is missing ${path}.`, { path });
246
258
  }
247
259
  try {
248
- return xmlParser.parse(decodeText(bytes));
260
+ return parser.parse(decodeText(bytes));
249
261
  } catch (error) {
250
262
  throw new OPFPptxError("invalid-pptx", `PPTX XML part could not be parsed: ${path}.`, {
251
263
  path,
@@ -346,7 +358,7 @@ function dimensionsFromPresentation(presentationRoot) {
346
358
  };
347
359
  }
348
360
 
349
- function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
361
+ function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
350
362
  const doc = parseRequiredXml(entries, slidePath);
351
363
  const slideRoot = doc["p:sld"];
352
364
  if (!slideRoot) {
@@ -360,17 +372,12 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
360
372
  const slide = {};
361
373
  if (slideRoot.show === "0") slide.hidden = true;
362
374
 
363
- const background = slideBackground(slideRoot);
364
- if (background) {
365
- slide.design = {
366
- background: {
367
- type: "solid",
368
- color: background
369
- }
370
- };
371
- }
375
+ const background = importBackground(slidePath, resolveCanvasDimensions(dimensions), {
376
+ part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
377
+ }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
378
+ if (background) slide.design = {background};
372
379
 
373
- const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions)
380
+ const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
374
381
  .sort(comparePositionedItems);
375
382
  const titleItem = takeTitleItem(items, dimensions);
376
383
  if (titleItem) slide.title = firstLine(titleItem.text);
@@ -385,14 +392,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
385
392
  const notes = readSlideNotes(entries, relationships);
386
393
  if (notes) slide.notes = notes;
387
394
 
388
- if (!slide.title && !slide.blocks && !slide.notes) {
389
- slide.title = `Slide ${slideIndex + 1}`;
390
- }
391
-
392
395
  return slide;
393
396
  }
394
397
 
395
- function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions) {
398
+ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
396
399
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
397
400
  const items = [];
398
401
 
@@ -406,8 +409,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
406
409
  if (item) items.push(item);
407
410
  }
408
411
 
409
- for (const picture of asArray(tree?.["p:pic"])) {
410
- const item = importPicture(entries, picture, slidePath, relationships);
412
+ for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
413
+ const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
414
+ const item = importPicture(entries, picture, slidePath, relationships, report);
411
415
  if (item) items.push(item);
412
416
  }
413
417
 
@@ -480,13 +484,13 @@ function importGraphicFrame(entries, frame, slidePath, relationships) {
480
484
  };
481
485
  }
482
486
 
483
- function importPicture(entries, picture, slidePath, relationships) {
487
+ function importPicture(entries, picture, slidePath, relationships, report) {
484
488
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
485
489
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
486
490
  const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
487
491
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
488
492
  const relationship = relationships.get(relId);
489
- const bytes = relationship?.path ? entries[relationship.path] : null;
493
+ let bytes = relationship?.path ? entries[relationship.path] : null;
490
494
  if (!bytes) {
491
495
  return {
492
496
  kind: "unknown",
@@ -496,6 +500,12 @@ function importPicture(entries, picture, slidePath, relationships) {
496
500
  };
497
501
  }
498
502
 
503
+ const crop = picture["p:blipFill"]?.["a:srcRect"];
504
+ if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
505
+ report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
506
+ }
507
+ bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
508
+
499
509
  return {
500
510
  kind: "image",
501
511
  bounds,
@@ -503,7 +513,7 @@ function importPicture(entries, picture, slidePath, relationships) {
503
513
  payload: {
504
514
  type: "image",
505
515
  image: {
506
- src: `data:${mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
516
+ src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
507
517
  ...(alt ? { alt } : {})
508
518
  }
509
519
  }
@@ -624,13 +634,14 @@ function payloadFromSlideItem(item) {
624
634
 
625
635
  function tableFromXml(table) {
626
636
  const rows = asArray(table["a:tr"])
627
- .map((row) => asArray(row?.["a:tc"]).map((cell) => textFromTextBody(cell?.["a:txBody"])))
628
- .filter((row) => row.some(Boolean));
629
- if (rows.length === 0) return { rows: [] };
630
- return {
631
- columns: rows[0],
632
- rows: rows.slice(1)
633
- };
637
+ .map((row) => asArray(row?.["a:tc"]).map((cell) => textFromTextBody(cell?.["a:txBody"])));
638
+ // DrawingML's firstRow flag applies header-row formatting. Without that
639
+ // signal, retain all rows as data instead of guessing from their contents.
640
+ const firstRow = table["a:tblPr"]?.firstRow;
641
+ const hasHeaders = firstRow === "1" || firstRow === "true";
642
+ return hasHeaders && rows.length
643
+ ? { columns: rows[0], rows: rows.slice(1) }
644
+ : { rows };
634
645
  }
635
646
 
636
647
  function chartFromRelationship(entries, slidePath, relationships, relId) {
@@ -715,10 +726,6 @@ function readSlideNotes(entries, relationships) {
715
726
  return bodyNotes.join("\n").trim();
716
727
  }
717
728
 
718
- function slideBackground(slideRoot) {
719
- const color = slideRoot["p:cSld"]?.["p:bg"]?.["p:bgPr"]?.["a:solidFill"]?.["a:srgbClr"]?.val;
720
- return color ? `#${normalizeHex(color)}` : "";
721
- }
722
729
 
723
730
  function textFromTextBody(txBody) {
724
731
  return readParagraphs(txBody).map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
@@ -755,7 +762,9 @@ function isFullSlide(bounds, dimensions) {
755
762
  function emuToInches(value) {
756
763
  const number = Number(value);
757
764
  if (!Number.isFinite(number)) return null;
758
- return Math.round((number / EMUS_PER_INCH) * 1_000_000) / 1_000_000;
765
+ // Keep native precision through layout/rendering. Rounding inches to six
766
+ // decimals turns a 1280-pixel canvas into 1279.999968 and changes raster edges.
767
+ return number / EMUS_PER_INCH;
759
768
  }
760
769
 
761
770
  function asArray(value) {
@@ -805,7 +814,7 @@ function assertValidBoundary(presentation) {
805
814
 
806
815
  function resolvePresentationContext(presentation, options) {
807
816
  const design = presentation.design ?? {};
808
- const theme = resolveCatalogRecord(presentation, "themes", design.theme, DEFAULTS.theme);
817
+ const theme = resolveDesignRecord(presentation, "themes", design.theme, DEFAULTS.theme);
809
818
  const colorScheme = resolveDesignRecord(
810
819
  presentation,
811
820
  "colorSchemes",
@@ -833,6 +842,7 @@ function resolvePresentationContext(presentation, options) {
833
842
  layoutName: "OPF_CANVAS",
834
843
  dimensions,
835
844
  colorScheme,
845
+ backgroundDefinition: design.background ?? theme?.background,
836
846
  fonts,
837
847
  colors: {
838
848
  background,
@@ -840,7 +850,7 @@ function resolvePresentationContext(presentation, options) {
840
850
  mutedText: normalizeHex(colorScheme.textSecondary ?? (darkBackground ? colorScheme.light2 : colorScheme.dark2) ?? "#475569"),
841
851
  accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
842
852
  surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
843
- border: normalizeHex(colorScheme.accent3 ?? "#CBD5E1")
853
+ border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
844
854
  }
845
855
  };
846
856
  }
@@ -867,6 +877,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
867
877
  const slide = pptx.addSlide();
868
878
  const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
869
879
  slide.background = { color: slideContext.colors.background };
880
+ const backgroundFill = nativeBackgroundFill(slideContext.backgroundDefinition, {
881
+ width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
882
+ }, slideContext.colors.background);
883
+ if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
870
884
  slide.color = slideContext.colors.text;
871
885
  if (opfSlide.hidden === true) slide.hidden = true;
872
886
 
@@ -897,7 +911,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
897
911
  } else if (item.field === "text" && typeof item.value === "string") {
898
912
  slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),fontFace:item.textStyle.fontFamily,bold:item.textStyle.fontWeight>=600,italic:item.textStyle.italic});
899
913
  } else {
900
- await addPayload(slide, presentation, item.payload, region, item.path, slideContext, options);
914
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options);
901
915
  }
902
916
  }
903
917
 
@@ -911,7 +925,7 @@ function resolveSlideContext(presentation, slide, baseContext, options) {
911
925
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
912
926
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
913
927
  }
914
- return { ...baseContext, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors };
928
+ return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, imageFill: effective.design.imageFill ?? "fit" };
915
929
  }
916
930
 
917
931
  function fieldToType(field) {
@@ -937,7 +951,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
937
951
  addChartPayload(slide, payload.chart, region, context);
938
952
  break;
939
953
  case "table":
940
- addTablePayload(slide, payload.table, region, context);
954
+ addTablePayload(slide, payload.table, region, context, options, path);
941
955
  break;
942
956
  case "code":
943
957
  addCodePayload(slide, payload.code, region, context);
@@ -1048,8 +1062,11 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1048
1062
  addPlaceholderPayload(slide, "Image", asset, region, context);
1049
1063
  return;
1050
1064
  }
1065
+ const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1066
+ context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1051
1067
  slide.addImage({
1052
1068
  ...resolved,
1069
+ objectName,
1053
1070
  x: region.x,
1054
1071
  y: region.y,
1055
1072
  w: region.w,
@@ -1084,36 +1101,49 @@ function addChartPayload(slide, chart, region, context) {
1084
1101
  });
1085
1102
  }
1086
1103
 
1087
- function addTablePayload(slide, table, region, context) {
1104
+ function addTablePayload(slide, table, region, context, options, path) {
1088
1105
  const scale = Math.min(context.dimensions.widthInches * 96, context.dimensions.heightInches * 96) / 720;
1089
- const rows = [];
1090
- if (Array.isArray(table?.columns) && table.columns.length > 0) {
1091
- rows.push(table.columns.map((value) => ({
1092
- text: stringifyText(value),
1093
- options: {
1094
- bold: true,
1095
- color: "FFFFFF",
1096
- fill: { color: context.colors.accent }
1097
- }
1098
- })));
1099
- }
1100
- if (Array.isArray(table?.rows)) {
1101
- for (const row of table.rows) {
1102
- rows.push((Array.isArray(row) ? row : [row]).map((value) => ({
1103
- text: stringifyText(value),
1104
- options: { color: context.colors.text, fill: {color:context.colors.surface} }
1105
- })));
1106
- }
1107
- }
1108
-
1109
- if (rows.length === 0) {
1106
+ const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
1107
+ const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
1108
+ if (sourceRows.length === 0) {
1110
1109
  addPlaceholderPayload(slide, "Table", table, region, context);
1111
1110
  return;
1112
1111
  }
1113
1112
 
1114
- const columnCount = Math.max(1, ...rows.map(row => row.length));
1115
- const rowHeight = Math.min(54 * scale / 96, region.h / rows.length);
1113
+ const columnCount = Math.max(1, ...sourceRows.map(row => row.length));
1114
+ const rowHeight = Math.min(54 * scale / 96, region.h / sourceRows.length);
1115
+ const cellBox = {
1116
+ x: 0, y: 0,
1117
+ width: Math.max(scale, region.w * 96 / columnCount - 20 * scale),
1118
+ height: Math.max(scale, rowHeight * 96 - 12 * scale),
1119
+ };
1120
+ const rows = sourceRows.map((row, rowIndex) => Array.from({ length: columnCount }, (_, columnIndex) => {
1121
+ const header = hasHeaders && rowIndex === 0;
1122
+ const cellPath = header ? `${path}.columns.${columnIndex}` : `${path}.rows.${rowIndex - Number(hasHeaders)}.${columnIndex}`;
1123
+ const text = stringifyText(row[columnIndex]);
1124
+ const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: header ? 700 : 400, italic: false, path: cellPath }, options.textMeasurement);
1125
+ const fit = fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
1126
+ return {
1127
+ // Keep native wrapping and the original cell value: inserting measured
1128
+ // soft wraps into the text would change a later import or copy operation.
1129
+ text,
1130
+ options: {
1131
+ fontFace: style.fontFamily,
1132
+ fontSize: fit.fontSize * 0.75,
1133
+ bold: style.fontWeight >= 600,
1134
+ italic: style.italic,
1135
+ lineSpacing: fit.lineHeight * 0.75,
1136
+ paraSpaceAfter: 0,
1137
+ align: context.contentAlignment,
1138
+ color: header ? "FFFFFF" : context.colors.text,
1139
+ fill: { color: header ? context.colors.accent : context.colors.surface },
1140
+ },
1141
+ };
1142
+ }));
1143
+ const objectName = `OPF table ${context.tableHeaders.size + 1}`;
1144
+ context.tableHeaders.set(objectName, hasHeaders);
1116
1145
  slide.addTable(rows, {
1146
+ objectName,
1117
1147
  x: region.x,
1118
1148
  y: region.y,
1119
1149
  w: region.w,
@@ -1477,7 +1507,7 @@ function isDarkHex(value) {
1477
1507
  return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
1478
1508
  }
1479
1509
 
1480
- function normalizePptxZip(raw, context) {
1510
+ async function normalizePptxZip(raw, context) {
1481
1511
  let entries;
1482
1512
  try {
1483
1513
  entries = unzipSync(raw);
@@ -1487,18 +1517,59 @@ function normalizePptxZip(raw, context) {
1487
1517
  });
1488
1518
  }
1489
1519
 
1520
+ const imageSources = new Map();
1521
+ for (const [part, bytes] of Object.entries(entries)) {
1522
+ if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
1523
+ const relationships = parseRelationships(entries, part);
1524
+ for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
1525
+ const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
1526
+ const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
1527
+ if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
1528
+ }
1529
+ }
1530
+ const imageMetadata = new Map();
1531
+ for (const [part, bytes] of Object.entries(entries)) {
1532
+ if (!part.startsWith('ppt/media/')) continue;
1533
+ let metadata = rasterMetadata(bytes);
1534
+ if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
1535
+ try {
1536
+ if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
1537
+ const png = await webpToPng(bytes);
1538
+ metadata = rasterMetadata(png);
1539
+ if (metadata?.mediaType !== 'image/png') throw new Error('The local decoder did not return a PNG.');
1540
+ entries[part] = png;
1541
+ } catch (error) {
1542
+ throw new OPFPptxError('image-conversion-failed', 'WebP could not be converted to a compatible PNG.', {path: imageSources.get(part) ?? part, cause: errorMessage(error)});
1543
+ }
1544
+ }
1545
+ imageMetadata.set(part, metadata);
1546
+ }
1490
1547
  const output = {};
1491
1548
  const renameMaps = buildRenameMaps(Object.keys(entries));
1549
+ // The host may transform assets or supply a filename/MIME hint that no
1550
+ // longer matches its bytes. Native package metadata must describe the bytes.
1551
+ renameMaps.media = new Map();
1552
+ for (const [path, metadata] of imageMetadata) {
1553
+ if (!metadata) continue;
1554
+ const extension = metadata.mediaType.slice('image/'.length);
1555
+ const currentExtension = path.split('.').at(-1).toLowerCase();
1556
+ if (currentExtension === extension || (extension === 'jpeg' && currentExtension === 'jpg')) continue;
1557
+ const target = path.replace(/\.[^/.]+$/, `.${extension}`);
1558
+ if (target !== path && Object.hasOwn(entries, target)) throw new OPFPptxError('packaging-failed', 'Normalized image paths collide.', {path, target});
1559
+ renameMaps.media.set(path, target);
1560
+ }
1492
1561
  for (const path of Object.keys(entries).sort()) {
1493
1562
  const normalizedPath = normalizePartPath(path, renameMaps);
1494
- const bytes = normalizePartBytes(path, entries[path], context, renameMaps);
1563
+ const bytes = normalizePartBytes(path, entries[path], context, renameMaps, entries, imageMetadata);
1495
1564
  output[normalizedPath] = [bytes, {
1496
1565
  level: context.compressionLevel,
1497
1566
  mtime: context.zipDate
1498
1567
  }];
1499
1568
  }
1500
1569
 
1501
- return zipSync(output, {
1570
+ // Sort after chart/worksheet renaming; source counters can cross digit widths.
1571
+ const sortedOutput = Object.fromEntries(Object.keys(output).sort().map(path => [path, output[path]]));
1572
+ return zipSync(sortedOutput, {
1502
1573
  level: context.compressionLevel,
1503
1574
  mtime: context.zipDate
1504
1575
  });
@@ -1510,7 +1581,8 @@ function normalizeCoreProperties(xml, timestamp) {
1510
1581
  .replace(/<dcterms:modified xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:modified>/g, `<dcterms:modified xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:modified>`);
1511
1582
  }
1512
1583
 
1513
- function normalizePartBytes(path, bytes, context, renameMaps) {
1584
+ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMetadata) {
1585
+ if (imageMetadata.has(path)) return normalizeImageOrientation(bytes, imageMetadata.get(path));
1514
1586
  if (path.endsWith(".xlsx")) {
1515
1587
  return normalizeNestedZip(bytes, context);
1516
1588
  }
@@ -1519,7 +1591,53 @@ function normalizePartBytes(path, bytes, context, renameMaps) {
1519
1591
  }
1520
1592
  if (isXmlPart(path)) {
1521
1593
  let xml=decodeText(bytes);
1594
+ if (path === '[Content_Types].xml') {
1595
+ // Explicit per-part types also correct PptxGenJS's image/jpg default.
1596
+ const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
1597
+ `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
1598
+ xml = xml.replace('</Types>', `${overrides}</Types>`);
1599
+ }
1522
1600
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
1601
+ const fill = context.backgroundFills.get(path);
1602
+ if (fill) xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
1603
+ // PptxGenJS table IDs can collide with other objects on the same slide.
1604
+ // Preserve existing IDs and allocate unused IDs only for duplicates. This
1605
+ // export path creates no connector attachments or animation ID references.
1606
+ const objectIds = [...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]));
1607
+ let nextObjectId = Math.max(0, ...objectIds) + 1;
1608
+ const seenObjectIds = new Set();
1609
+ xml = xml.replace(/(<p:cNvPr\b[^>]*\bid=")(\d+)(")/g, (node, before, rawId, after) => {
1610
+ const id = Number(rawId);
1611
+ if (seenObjectIds.has(id)) return `${before}${nextObjectId++}${after}`;
1612
+ seenObjectIds.add(id);
1613
+ return node;
1614
+ });
1615
+ // PptxGenJS 4 has no firstRow option. Set the native flag explicitly so
1616
+ // viewers and later imports distinguish column labels from data rows.
1617
+ xml = xml.replace(/<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g, frame => {
1618
+ const name = frame.match(/name="(OPF table \d+)"/)?.[1];
1619
+ if (!context.tableHeaders.has(name)) return frame;
1620
+ return frame.replace('<a:tblPr/>', `<a:tblPr firstRow="${context.tableHeaders.get(name) ? 1 : 0}"/>`);
1621
+ });
1622
+ // Image data is already resolved and embedded by PptxGenJS. Read those
1623
+ // exact bytes instead of fetching or resolving the source a second time.
1624
+ const relationships = parseRelationships(entries, path);
1625
+ xml = xml.replace(/<p:pic>([\s\S]*?)<\/p:pic>/g, picture => {
1626
+ const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
1627
+ if (!placement) return picture;
1628
+ const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
1629
+ const dimensions = imageMetadata.get(relationships.get(id)?.path);
1630
+ if (!dimensions) throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path: placement.path });
1631
+ const fitted = pictureTransform(dimensions, placement.region, placement.mode);
1632
+ const emu = value => Math.round(value * EMUS_PER_INCH);
1633
+ const transformAttrs = `${fitted.rotation ? ` rot="${fitted.rotation * 60000}"` : ''}${fitted.flipH ? ' flipH="1"' : ''}${fitted.flipV ? ' flipV="1"' : ''}`;
1634
+ picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${transformAttrs}><a:off x="${emu(fitted.x)}" y="${emu(fitted.y)}"/><a:ext cx="${emu(fitted.w)}" cy="${emu(fitted.h)}"/></a:xfrm>`);
1635
+ if (fitted.crop) {
1636
+ const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
1637
+ picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
1638
+ }
1639
+ return picture;
1640
+ });
1523
1641
  // Native bullets otherwise inherit the first rich run's size, font and
1524
1642
  // color, which can differ from the measured list marker.
1525
1643
  xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
@@ -1561,7 +1679,7 @@ function numberedFilenameMap(paths, pattern) {
1561
1679
  }
1562
1680
 
1563
1681
  function normalizePartPath(path, renameMaps) {
1564
- return normalizePartReferences(path, renameMaps);
1682
+ return normalizePartReferences(renameMaps.media?.get(path) ?? path, renameMaps);
1565
1683
  }
1566
1684
 
1567
1685
  function normalizePartReferences(value, renameMaps) {
@@ -1575,6 +1693,14 @@ function normalizePartReferences(value, renameMaps) {
1575
1693
  `Microsoft_Excel_Worksheet${newId}.xlsx`
1576
1694
  );
1577
1695
  }
1696
+ // Rewrite package references only, not user-visible text containing paths.
1697
+ output = output.replace(/\b(Target|PartName)="([^"]+)"/g, (attribute, name, value) => {
1698
+ const prefix = value.startsWith('../media/') ? '../' : value.startsWith('/ppt/media/') ? '/ppt/' : null;
1699
+ if (!prefix) return attribute;
1700
+ const part = 'ppt/' + value.slice(prefix.length);
1701
+ const target = renameMaps.media?.get(part);
1702
+ return target ? `${name}="${prefix}${target.slice('ppt/'.length)}"` : attribute;
1703
+ });
1578
1704
  return output;
1579
1705
  }
1580
1706
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "engines": {
9
- "node": ">=20"
9
+ "node": ">=20.9.0"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -35,23 +35,35 @@
35
35
  "sideEffects": false,
36
36
  "scripts": {
37
37
  "build": "node scripts/build.mjs",
38
- "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs",
39
- "test": "npm run build && node test/dependency-boundary.mjs",
38
+ "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs",
39
+ "test": "npm run build && node test/dependency-boundary.mjs && npm run build:browser-check",
40
40
  "validate": "node scripts/validate-package.mjs",
41
- "prepack": "npm run build"
41
+ "prepack": "npm run build",
42
+ "build:browser-check": "node scripts/build-browser-check.mjs"
42
43
  },
43
44
  "dependencies": {
44
- "@openpresentation/opf": "^0.4.0",
45
+ "@openpresentation/opf": "^0.4.1",
45
46
  "fast-xml-parser": "^5.8.0",
46
47
  "fflate": "^0.8.3",
47
- "pptxgenjs": "4.0.1"
48
+ "pptxgenjs": "4.0.1",
49
+ "sharp": "0.35.4"
48
50
  },
49
51
  "peerDependencies": {
50
- "@openpresentation/opf-render": "^0.1.0"
52
+ "@openpresentation/opf-render": "^0.2.0"
51
53
  },
52
54
  "peerDependenciesMeta": {
53
55
  "@openpresentation/opf-render": {
54
56
  "optional": true
55
57
  }
58
+ },
59
+ "devDependencies": {
60
+ "@openpresentation/opf-render": "0.2.0",
61
+ "esbuild": "0.28.2"
62
+ },
63
+ "imports": {
64
+ "#image-fallback": {
65
+ "browser": "./dist/image-fallback-browser.js",
66
+ "default": "./dist/image-fallback-node.js"
67
+ }
56
68
  }
57
69
  }