@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 +60 -0
- package/dist/background.js +81 -0
- package/dist/image-fallback-browser.js +33 -0
- package/dist/image-fallback-node.js +8 -0
- package/dist/image-geometry.js +113 -0
- package/dist/image-import.js +92 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +192 -69
- package/package.json +20 -8
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
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, ...
|
|
1115
|
-
const rowHeight = Math.min(54 * scale / 96, region.h /
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|