@openpresentation/opf-pptx 0.0.1 → 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 +72 -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 +7 -0
- package/dist/index.js +321 -289
- 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.
|
|
@@ -106,3 +108,73 @@ Required first-publish setup:
|
|
|
106
108
|
3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
|
|
107
109
|
|
|
108
110
|
This repo does not require an npm automation token when Trusted Publishing is configured.
|
|
111
|
+
|
|
112
|
+
## Shared dynamic composition (local development)
|
|
113
|
+
|
|
114
|
+
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
115
|
+
|
|
116
|
+
Version 0.1.0 requires published `@openpresentation/opf@^0.4.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.1.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
117
|
+
|
|
118
|
+
For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagination` first, then pass its returned presentation to both preview and `toPptx`. Native table row sizing now follows shared reference geometry; the exporter does not add hidden table continuation slides.
|
|
119
|
+
|
|
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.
|
|
121
|
+
|
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
|
|
1
2
|
export declare const packageName = "@openpresentation/opf-pptx";
|
|
2
3
|
|
|
3
4
|
export declare const releaseLane: Readonly<{
|
|
@@ -33,6 +34,10 @@ export interface ImageResolverContext {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface ToPptxOptions {
|
|
37
|
+
/** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
|
|
38
|
+
imageFormat?: "compatible" | "preserve";
|
|
39
|
+
textMeasurement?: TextMeasurement;
|
|
40
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
|
|
36
41
|
baseDir?: string;
|
|
37
42
|
compressionLevel?: number;
|
|
38
43
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|
|
@@ -43,6 +48,8 @@ export interface ToPptxOptions {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
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;
|
|
46
53
|
fallbackName?: string;
|
|
47
54
|
schema?: string;
|
|
48
55
|
}
|