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