@openpresentation/opf-pptx 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,61 @@ 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, transformed/theme stop colors, 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
+ 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.
177
+
178
+ 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.
179
+
180
+ Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
@@ -0,0 +1,81 @@
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') return `<a:solidFill>${colorXml(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
+ function readColor(node) {
42
+ const c = node?.['a:srgbClr'];
43
+ if (!c || !/^[\da-f]{6}$/i.test(c.val) || Object.keys(c).some(k => k !== 'val' && k !== 'a:alpha')) return null;
44
+ const alpha = Number(c['a:alpha']?.val ?? 100000) / 100000;
45
+ if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return null;
46
+ return {hex: '#' + c.val.toUpperCase(), alpha};
47
+ }
48
+
49
+ export function readNativeBackground(properties, {width, height}, report = () => {}) {
50
+ if (!properties) return undefined;
51
+ if (Object.hasOwn(properties, 'a:noFill')) return {type: 'solid', color: '#FFFFFF', opacity: 0};
52
+ const solid = readColor(properties['a:solidFill']);
53
+ if (solid) return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
54
+ const gradient = properties['a:gradFill'];
55
+ if (!gradient) return undefined;
56
+ const unsupported = () => {
57
+ 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.'});
58
+ return undefined;
59
+ };
60
+ const lin = gradient['a:lin'];
61
+ if (!lin || gradient['a:path'] || (gradient.flip && gradient.flip !== 'none')
62
+ || Object.values(gradient['a:tileRect'] ?? {}).some(value => Number(value) !== 0)) return unsupported();
63
+ let radians = Number(lin.ang ?? 0) / 60000 * Math.PI / 180;
64
+ if (!Number.isFinite(radians)) return unsupported();
65
+ // DrawingML scaled=true first scales the direction by the fill dimensions.
66
+ if (lin.scaled === '1' || lin.scaled === 'true') radians = Math.atan2(height * Math.sin(radians), width * Math.cos(radians));
67
+ const angle = turn(Math.atan2(height * Math.sin(radians), width * Math.cos(radians)) * 180 / Math.PI);
68
+ const a = angle * Math.PI / 180, span = Math.abs(Math.cos(a)) + Math.abs(Math.sin(a));
69
+ const stops = list(gradient['a:gsLst']?.['a:gs']).map(stop => {
70
+ const c = readColor(stop), position = (Number(stop.pos) / 100000 - .5) * span + .5;
71
+ // Allow only native integer rounding, not a lossy clamping of arbitrary
72
+ // corner-to-corner gradients that OPF's fixed endpoints cannot represent.
73
+ if (!c || !Number.isFinite(position) || position < -.00002 || position > 1.00002) return null;
74
+ return {...c, position: clamp(position)};
75
+ });
76
+ if (!stops.length || stops.some(stop => !stop)) return unsupported();
77
+ const alpha = stops[0].alpha, uniform = stops.every(stop => stop.alpha === alpha);
78
+ return {type: 'gradient', gradient: {angle, stops: stops.map(stop => ({position: stop.position,
79
+ color: stop.hex + (!uniform && stop.alpha !== 1 ? Math.round(stop.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '')}))},
80
+ ...(uniform && alpha !== 1 ? {opacity: alpha} : {})};
81
+ }
@@ -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,8 @@
1
- import { composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
1
+ import {importImageOrientation} from './image-import.js';
2
+ import {nativeBackgroundFill, readNativeBackground} from './background.js';
3
+ import { webpToPng } from '#image-fallback';
4
+ import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
5
+ import { composeSlide, fitText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
2
6
  import PptxGenJS from "pptxgenjs";
3
7
  import { unzipSync, zipSync } from "fflate";
4
8
  import { XMLParser } from "fast-xml-parser";
@@ -152,11 +156,18 @@ const CHART_COLORS = [
152
156
  ];
153
157
 
154
158
  export async function toPptx(input, options = {}) {
159
+ if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
160
+ throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
161
+ }
155
162
  const presentation = parseInput(input);
156
163
  assertValidBoundary(presentation);
157
164
 
158
165
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
159
166
  context.listMarkers = new Map();
167
+ context.tableHeaders = new Map();
168
+ context.imagePlacements = new Map();
169
+ context.backgroundFills = new Map();
170
+ context.imageFormat = options.imageFormat ?? "compatible";
160
171
  const pptx = new PptxGenJS();
161
172
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
162
173
 
@@ -206,7 +217,7 @@ export async function fromPptx(input, options = {}) {
206
217
  if (dimensions) imported.design = { dimensions };
207
218
 
208
219
  for (let index = 0; index < slidePaths.length; index += 1) {
209
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions));
220
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
210
221
  }
211
222
 
212
223
  const result = validatePresentation(imported);
@@ -346,7 +357,7 @@ function dimensionsFromPresentation(presentationRoot) {
346
357
  };
347
358
  }
348
359
 
349
- function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
360
+ function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
350
361
  const doc = parseRequiredXml(entries, slidePath);
351
362
  const slideRoot = doc["p:sld"];
352
363
  if (!slideRoot) {
@@ -360,17 +371,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
360
371
  const slide = {};
361
372
  if (slideRoot.show === "0") slide.hidden = true;
362
373
 
363
- const background = slideBackground(slideRoot);
364
- if (background) {
365
- slide.design = {
366
- background: {
367
- type: "solid",
368
- color: background
369
- }
370
- };
371
- }
374
+ const background = readNativeBackground(slideRoot["p:cSld"]?.["p:bg"]?.["p:bgPr"], resolveCanvasDimensions(dimensions), diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
375
+ if (background) slide.design = {background};
372
376
 
373
- const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions)
377
+ const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
374
378
  .sort(comparePositionedItems);
375
379
  const titleItem = takeTitleItem(items, dimensions);
376
380
  if (titleItem) slide.title = firstLine(titleItem.text);
@@ -385,14 +389,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
385
389
  const notes = readSlideNotes(entries, relationships);
386
390
  if (notes) slide.notes = notes;
387
391
 
388
- if (!slide.title && !slide.blocks && !slide.notes) {
389
- slide.title = `Slide ${slideIndex + 1}`;
390
- }
391
-
392
392
  return slide;
393
393
  }
394
394
 
395
- function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions) {
395
+ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
396
396
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
397
397
  const items = [];
398
398
 
@@ -406,8 +406,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
406
406
  if (item) items.push(item);
407
407
  }
408
408
 
409
- for (const picture of asArray(tree?.["p:pic"])) {
410
- const item = importPicture(entries, picture, slidePath, relationships);
409
+ for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
410
+ const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
411
+ const item = importPicture(entries, picture, slidePath, relationships, report);
411
412
  if (item) items.push(item);
412
413
  }
413
414
 
@@ -480,13 +481,13 @@ function importGraphicFrame(entries, frame, slidePath, relationships) {
480
481
  };
481
482
  }
482
483
 
483
- function importPicture(entries, picture, slidePath, relationships) {
484
+ function importPicture(entries, picture, slidePath, relationships, report) {
484
485
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
485
486
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
486
487
  const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
487
488
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
488
489
  const relationship = relationships.get(relId);
489
- const bytes = relationship?.path ? entries[relationship.path] : null;
490
+ let bytes = relationship?.path ? entries[relationship.path] : null;
490
491
  if (!bytes) {
491
492
  return {
492
493
  kind: "unknown",
@@ -496,6 +497,12 @@ function importPicture(entries, picture, slidePath, relationships) {
496
497
  };
497
498
  }
498
499
 
500
+ const crop = picture["p:blipFill"]?.["a:srcRect"];
501
+ if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
502
+ report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
503
+ }
504
+ bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
505
+
499
506
  return {
500
507
  kind: "image",
501
508
  bounds,
@@ -503,7 +510,7 @@ function importPicture(entries, picture, slidePath, relationships) {
503
510
  payload: {
504
511
  type: "image",
505
512
  image: {
506
- src: `data:${mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
513
+ src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
507
514
  ...(alt ? { alt } : {})
508
515
  }
509
516
  }
@@ -624,13 +631,14 @@ function payloadFromSlideItem(item) {
624
631
 
625
632
  function tableFromXml(table) {
626
633
  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
- };
634
+ .map((row) => asArray(row?.["a:tc"]).map((cell) => textFromTextBody(cell?.["a:txBody"])));
635
+ // DrawingML's firstRow flag applies header-row formatting. Without that
636
+ // signal, retain all rows as data instead of guessing from their contents.
637
+ const firstRow = table["a:tblPr"]?.firstRow;
638
+ const hasHeaders = firstRow === "1" || firstRow === "true";
639
+ return hasHeaders && rows.length
640
+ ? { columns: rows[0], rows: rows.slice(1) }
641
+ : { rows };
634
642
  }
635
643
 
636
644
  function chartFromRelationship(entries, slidePath, relationships, relId) {
@@ -715,10 +723,6 @@ function readSlideNotes(entries, relationships) {
715
723
  return bodyNotes.join("\n").trim();
716
724
  }
717
725
 
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
726
 
723
727
  function textFromTextBody(txBody) {
724
728
  return readParagraphs(txBody).map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
@@ -755,7 +759,9 @@ function isFullSlide(bounds, dimensions) {
755
759
  function emuToInches(value) {
756
760
  const number = Number(value);
757
761
  if (!Number.isFinite(number)) return null;
758
- return Math.round((number / EMUS_PER_INCH) * 1_000_000) / 1_000_000;
762
+ // Keep native precision through layout/rendering. Rounding inches to six
763
+ // decimals turns a 1280-pixel canvas into 1279.999968 and changes raster edges.
764
+ return number / EMUS_PER_INCH;
759
765
  }
760
766
 
761
767
  function asArray(value) {
@@ -805,7 +811,7 @@ function assertValidBoundary(presentation) {
805
811
 
806
812
  function resolvePresentationContext(presentation, options) {
807
813
  const design = presentation.design ?? {};
808
- const theme = resolveCatalogRecord(presentation, "themes", design.theme, DEFAULTS.theme);
814
+ const theme = resolveDesignRecord(presentation, "themes", design.theme, DEFAULTS.theme);
809
815
  const colorScheme = resolveDesignRecord(
810
816
  presentation,
811
817
  "colorSchemes",
@@ -833,6 +839,7 @@ function resolvePresentationContext(presentation, options) {
833
839
  layoutName: "OPF_CANVAS",
834
840
  dimensions,
835
841
  colorScheme,
842
+ backgroundDefinition: design.background ?? theme?.background,
836
843
  fonts,
837
844
  colors: {
838
845
  background,
@@ -840,7 +847,7 @@ function resolvePresentationContext(presentation, options) {
840
847
  mutedText: normalizeHex(colorScheme.textSecondary ?? (darkBackground ? colorScheme.light2 : colorScheme.dark2) ?? "#475569"),
841
848
  accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
842
849
  surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
843
- border: normalizeHex(colorScheme.accent3 ?? "#CBD5E1")
850
+ border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
844
851
  }
845
852
  };
846
853
  }
@@ -867,6 +874,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
867
874
  const slide = pptx.addSlide();
868
875
  const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
869
876
  slide.background = { color: slideContext.colors.background };
877
+ const backgroundFill = nativeBackgroundFill(slideContext.backgroundDefinition, {
878
+ width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
879
+ }, slideContext.colors.background);
880
+ if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
870
881
  slide.color = slideContext.colors.text;
871
882
  if (opfSlide.hidden === true) slide.hidden = true;
872
883
 
@@ -897,7 +908,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
897
908
  } else if (item.field === "text" && typeof item.value === "string") {
898
909
  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
910
  } else {
900
- await addPayload(slide, presentation, item.payload, region, item.path, slideContext, options);
911
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options);
901
912
  }
902
913
  }
903
914
 
@@ -911,7 +922,7 @@ function resolveSlideContext(presentation, slide, baseContext, options) {
911
922
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
912
923
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
913
924
  }
914
- return { ...baseContext, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors };
925
+ return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, imageFill: effective.design.imageFill ?? "fit" };
915
926
  }
916
927
 
917
928
  function fieldToType(field) {
@@ -937,7 +948,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
937
948
  addChartPayload(slide, payload.chart, region, context);
938
949
  break;
939
950
  case "table":
940
- addTablePayload(slide, payload.table, region, context);
951
+ addTablePayload(slide, payload.table, region, context, options, path);
941
952
  break;
942
953
  case "code":
943
954
  addCodePayload(slide, payload.code, region, context);
@@ -1048,8 +1059,11 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1048
1059
  addPlaceholderPayload(slide, "Image", asset, region, context);
1049
1060
  return;
1050
1061
  }
1062
+ const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1063
+ context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1051
1064
  slide.addImage({
1052
1065
  ...resolved,
1066
+ objectName,
1053
1067
  x: region.x,
1054
1068
  y: region.y,
1055
1069
  w: region.w,
@@ -1084,36 +1098,49 @@ function addChartPayload(slide, chart, region, context) {
1084
1098
  });
1085
1099
  }
1086
1100
 
1087
- function addTablePayload(slide, table, region, context) {
1101
+ function addTablePayload(slide, table, region, context, options, path) {
1088
1102
  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) {
1103
+ const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
1104
+ const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
1105
+ if (sourceRows.length === 0) {
1110
1106
  addPlaceholderPayload(slide, "Table", table, region, context);
1111
1107
  return;
1112
1108
  }
1113
1109
 
1114
- const columnCount = Math.max(1, ...rows.map(row => row.length));
1115
- const rowHeight = Math.min(54 * scale / 96, region.h / rows.length);
1110
+ const columnCount = Math.max(1, ...sourceRows.map(row => row.length));
1111
+ const rowHeight = Math.min(54 * scale / 96, region.h / sourceRows.length);
1112
+ const cellBox = {
1113
+ x: 0, y: 0,
1114
+ width: Math.max(scale, region.w * 96 / columnCount - 20 * scale),
1115
+ height: Math.max(scale, rowHeight * 96 - 12 * scale),
1116
+ };
1117
+ const rows = sourceRows.map((row, rowIndex) => Array.from({ length: columnCount }, (_, columnIndex) => {
1118
+ const header = hasHeaders && rowIndex === 0;
1119
+ const cellPath = header ? `${path}.columns.${columnIndex}` : `${path}.rows.${rowIndex - Number(hasHeaders)}.${columnIndex}`;
1120
+ const text = stringifyText(row[columnIndex]);
1121
+ const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: header ? 700 : 400, italic: false, path: cellPath }, options.textMeasurement);
1122
+ const fit = fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
1123
+ return {
1124
+ // Keep native wrapping and the original cell value: inserting measured
1125
+ // soft wraps into the text would change a later import or copy operation.
1126
+ text,
1127
+ options: {
1128
+ fontFace: style.fontFamily,
1129
+ fontSize: fit.fontSize * 0.75,
1130
+ bold: style.fontWeight >= 600,
1131
+ italic: style.italic,
1132
+ lineSpacing: fit.lineHeight * 0.75,
1133
+ paraSpaceAfter: 0,
1134
+ align: context.contentAlignment,
1135
+ color: header ? "FFFFFF" : context.colors.text,
1136
+ fill: { color: header ? context.colors.accent : context.colors.surface },
1137
+ },
1138
+ };
1139
+ }));
1140
+ const objectName = `OPF table ${context.tableHeaders.size + 1}`;
1141
+ context.tableHeaders.set(objectName, hasHeaders);
1116
1142
  slide.addTable(rows, {
1143
+ objectName,
1117
1144
  x: region.x,
1118
1145
  y: region.y,
1119
1146
  w: region.w,
@@ -1477,7 +1504,7 @@ function isDarkHex(value) {
1477
1504
  return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
1478
1505
  }
1479
1506
 
1480
- function normalizePptxZip(raw, context) {
1507
+ async function normalizePptxZip(raw, context) {
1481
1508
  let entries;
1482
1509
  try {
1483
1510
  entries = unzipSync(raw);
@@ -1487,18 +1514,59 @@ function normalizePptxZip(raw, context) {
1487
1514
  });
1488
1515
  }
1489
1516
 
1517
+ const imageSources = new Map();
1518
+ for (const [part, bytes] of Object.entries(entries)) {
1519
+ if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
1520
+ const relationships = parseRelationships(entries, part);
1521
+ for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
1522
+ const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
1523
+ const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
1524
+ if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
1525
+ }
1526
+ }
1527
+ const imageMetadata = new Map();
1528
+ for (const [part, bytes] of Object.entries(entries)) {
1529
+ if (!part.startsWith('ppt/media/')) continue;
1530
+ let metadata = rasterMetadata(bytes);
1531
+ if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
1532
+ try {
1533
+ if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
1534
+ const png = await webpToPng(bytes);
1535
+ metadata = rasterMetadata(png);
1536
+ if (metadata?.mediaType !== 'image/png') throw new Error('The local decoder did not return a PNG.');
1537
+ entries[part] = png;
1538
+ } catch (error) {
1539
+ throw new OPFPptxError('image-conversion-failed', 'WebP could not be converted to a compatible PNG.', {path: imageSources.get(part) ?? part, cause: errorMessage(error)});
1540
+ }
1541
+ }
1542
+ imageMetadata.set(part, metadata);
1543
+ }
1490
1544
  const output = {};
1491
1545
  const renameMaps = buildRenameMaps(Object.keys(entries));
1546
+ // The host may transform assets or supply a filename/MIME hint that no
1547
+ // longer matches its bytes. Native package metadata must describe the bytes.
1548
+ renameMaps.media = new Map();
1549
+ for (const [path, metadata] of imageMetadata) {
1550
+ if (!metadata) continue;
1551
+ const extension = metadata.mediaType.slice('image/'.length);
1552
+ const currentExtension = path.split('.').at(-1).toLowerCase();
1553
+ if (currentExtension === extension || (extension === 'jpeg' && currentExtension === 'jpg')) continue;
1554
+ const target = path.replace(/\.[^/.]+$/, `.${extension}`);
1555
+ if (target !== path && Object.hasOwn(entries, target)) throw new OPFPptxError('packaging-failed', 'Normalized image paths collide.', {path, target});
1556
+ renameMaps.media.set(path, target);
1557
+ }
1492
1558
  for (const path of Object.keys(entries).sort()) {
1493
1559
  const normalizedPath = normalizePartPath(path, renameMaps);
1494
- const bytes = normalizePartBytes(path, entries[path], context, renameMaps);
1560
+ const bytes = normalizePartBytes(path, entries[path], context, renameMaps, entries, imageMetadata);
1495
1561
  output[normalizedPath] = [bytes, {
1496
1562
  level: context.compressionLevel,
1497
1563
  mtime: context.zipDate
1498
1564
  }];
1499
1565
  }
1500
1566
 
1501
- return zipSync(output, {
1567
+ // Sort after chart/worksheet renaming; source counters can cross digit widths.
1568
+ const sortedOutput = Object.fromEntries(Object.keys(output).sort().map(path => [path, output[path]]));
1569
+ return zipSync(sortedOutput, {
1502
1570
  level: context.compressionLevel,
1503
1571
  mtime: context.zipDate
1504
1572
  });
@@ -1510,7 +1578,8 @@ function normalizeCoreProperties(xml, timestamp) {
1510
1578
  .replace(/<dcterms:modified xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:modified>/g, `<dcterms:modified xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:modified>`);
1511
1579
  }
1512
1580
 
1513
- function normalizePartBytes(path, bytes, context, renameMaps) {
1581
+ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMetadata) {
1582
+ if (imageMetadata.has(path)) return normalizeImageOrientation(bytes, imageMetadata.get(path));
1514
1583
  if (path.endsWith(".xlsx")) {
1515
1584
  return normalizeNestedZip(bytes, context);
1516
1585
  }
@@ -1519,7 +1588,53 @@ function normalizePartBytes(path, bytes, context, renameMaps) {
1519
1588
  }
1520
1589
  if (isXmlPart(path)) {
1521
1590
  let xml=decodeText(bytes);
1591
+ if (path === '[Content_Types].xml') {
1592
+ // Explicit per-part types also correct PptxGenJS's image/jpg default.
1593
+ const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
1594
+ `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
1595
+ xml = xml.replace('</Types>', `${overrides}</Types>`);
1596
+ }
1522
1597
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
1598
+ const fill = context.backgroundFills.get(path);
1599
+ if (fill) xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
1600
+ // PptxGenJS table IDs can collide with other objects on the same slide.
1601
+ // Preserve existing IDs and allocate unused IDs only for duplicates. This
1602
+ // export path creates no connector attachments or animation ID references.
1603
+ const objectIds = [...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]));
1604
+ let nextObjectId = Math.max(0, ...objectIds) + 1;
1605
+ const seenObjectIds = new Set();
1606
+ xml = xml.replace(/(<p:cNvPr\b[^>]*\bid=")(\d+)(")/g, (node, before, rawId, after) => {
1607
+ const id = Number(rawId);
1608
+ if (seenObjectIds.has(id)) return `${before}${nextObjectId++}${after}`;
1609
+ seenObjectIds.add(id);
1610
+ return node;
1611
+ });
1612
+ // PptxGenJS 4 has no firstRow option. Set the native flag explicitly so
1613
+ // viewers and later imports distinguish column labels from data rows.
1614
+ xml = xml.replace(/<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g, frame => {
1615
+ const name = frame.match(/name="(OPF table \d+)"/)?.[1];
1616
+ if (!context.tableHeaders.has(name)) return frame;
1617
+ return frame.replace('<a:tblPr/>', `<a:tblPr firstRow="${context.tableHeaders.get(name) ? 1 : 0}"/>`);
1618
+ });
1619
+ // Image data is already resolved and embedded by PptxGenJS. Read those
1620
+ // exact bytes instead of fetching or resolving the source a second time.
1621
+ const relationships = parseRelationships(entries, path);
1622
+ xml = xml.replace(/<p:pic>([\s\S]*?)<\/p:pic>/g, picture => {
1623
+ const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
1624
+ if (!placement) return picture;
1625
+ const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
1626
+ const dimensions = imageMetadata.get(relationships.get(id)?.path);
1627
+ 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 });
1628
+ const fitted = pictureTransform(dimensions, placement.region, placement.mode);
1629
+ const emu = value => Math.round(value * EMUS_PER_INCH);
1630
+ const transformAttrs = `${fitted.rotation ? ` rot="${fitted.rotation * 60000}"` : ''}${fitted.flipH ? ' flipH="1"' : ''}${fitted.flipV ? ' flipV="1"' : ''}`;
1631
+ 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>`);
1632
+ if (fitted.crop) {
1633
+ const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
1634
+ picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
1635
+ }
1636
+ return picture;
1637
+ });
1523
1638
  // Native bullets otherwise inherit the first rich run's size, font and
1524
1639
  // color, which can differ from the measured list marker.
1525
1640
  xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
@@ -1561,7 +1676,7 @@ function numberedFilenameMap(paths, pattern) {
1561
1676
  }
1562
1677
 
1563
1678
  function normalizePartPath(path, renameMaps) {
1564
- return normalizePartReferences(path, renameMaps);
1679
+ return normalizePartReferences(renameMaps.media?.get(path) ?? path, renameMaps);
1565
1680
  }
1566
1681
 
1567
1682
  function normalizePartReferences(value, renameMaps) {
@@ -1575,6 +1690,14 @@ function normalizePartReferences(value, renameMaps) {
1575
1690
  `Microsoft_Excel_Worksheet${newId}.xlsx`
1576
1691
  );
1577
1692
  }
1693
+ // Rewrite package references only, not user-visible text containing paths.
1694
+ output = output.replace(/\b(Target|PartName)="([^"]+)"/g, (attribute, name, value) => {
1695
+ const prefix = value.startsWith('../media/') ? '../' : value.startsWith('/ppt/media/') ? '/ppt/' : null;
1696
+ if (!prefix) return attribute;
1697
+ const part = 'ppt/' + value.slice(prefix.length);
1698
+ const target = renameMaps.media?.get(part);
1699
+ return target ? `${name}="${prefix}${target.slice('ppt/'.length)}"` : attribute;
1700
+ });
1578
1701
  return output;
1579
1702
  }
1580
1703
 
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.0",
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",
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
  }