@openpresentation/opf-pptx 0.2.0 → 0.3.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 +14 -2
- package/dist/background-import.js +88 -0
- package/dist/background.js +66 -11
- package/dist/index.js +38 -9
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -121,7 +121,9 @@ Pass the same `textMeasurement` provider used by preview and pagination to `toPp
|
|
|
121
121
|
|
|
122
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
123
|
|
|
124
|
-
### Native table fitting
|
|
124
|
+
### Native table fitting
|
|
125
|
+
|
|
126
|
+
Core 0.5.0 accepts `TextRun[]` cells and headers. The 0.3.0 exporter preserves their resolved fonts, emphasis, color/alpha, hyperlinks, script positions and explicit line breaks as editable native runs. It measures rich content before export without inserting measured soft wraps. These changes are not in 0.2.1; version 0.3.0 requires core 0.5.0 and renderer 0.3.0. Native table import still flattens rich runs to strings, and native PowerPoint rendering remains unverified.
|
|
125
127
|
|
|
126
128
|
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
129
|
|
|
@@ -160,7 +162,7 @@ Fixed solid and linear-gradient backgrounds now export as native slide fills, ke
|
|
|
160
162
|
|
|
161
163
|
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
164
|
|
|
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,
|
|
165
|
+
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
166
|
|
|
165
167
|
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
168
|
|
|
@@ -173,6 +175,16 @@ When a JPEG has no orientation tag, import either adds a minimal EXIF segment or
|
|
|
173
175
|
|
|
174
176
|
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
177
|
|
|
178
|
+
### Inherited native backgrounds (0.2.1)
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
176
188
|
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
189
|
|
|
178
190
|
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.
|
|
@@ -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
|
+
}
|
package/dist/background.js
CHANGED
|
@@ -20,7 +20,7 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
|
|
|
20
20
|
if (typeof background === 'string' && /^#[\da-f]{3}(?:[\da-f]{3}(?:[\da-f]{2})?)?$/i.test(background)) background = {type: 'solid', color: background};
|
|
21
21
|
if (!background || typeof background !== 'object') return null;
|
|
22
22
|
const opacity = background.opacity ?? 1;
|
|
23
|
-
if (background.type === 'solid') return `<a:solidFill>${colorXml(background.color, opacity, fallback)}</a:solidFill>`;
|
|
23
|
+
if (background.type === 'solid' || background.type === 'theme') return `<a:solidFill>${colorXml(background.type === 'theme' ? fallback : background.color, opacity, fallback)}</a:solidFill>`;
|
|
24
24
|
if (background.type !== 'gradient') return null;
|
|
25
25
|
const stops = background.gradient?.stops ?? [];
|
|
26
26
|
if (!stops.length) return '<a:noFill/>';
|
|
@@ -38,21 +38,76 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
|
|
|
38
38
|
return `<a:gradFill rotWithShape="0"><a:gsLst>${nativeStops}</a:gsLst><a:lin ang="${angle}" scaled="0"/></a:gradFill>`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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};
|
|
47
99
|
}
|
|
48
100
|
|
|
49
|
-
export function readNativeBackground(properties, {width, height}, report = () => {}) {
|
|
101
|
+
export function readNativeBackground(properties, {width, height}, report = () => {}, context = {}) {
|
|
50
102
|
if (!properties) return undefined;
|
|
51
103
|
if (Object.hasOwn(properties, 'a:noFill')) return {type: 'solid', color: '#FFFFFF', opacity: 0};
|
|
52
|
-
const solid =
|
|
104
|
+
const solid = readBackgroundColor(properties['a:solidFill'], context);
|
|
53
105
|
if (solid) return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
|
|
54
106
|
const gradient = properties['a:gradFill'];
|
|
55
|
-
if (!gradient)
|
|
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
|
+
}
|
|
56
111
|
const unsupported = () => {
|
|
57
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.'});
|
|
58
113
|
return undefined;
|
|
@@ -67,7 +122,7 @@ export function readNativeBackground(properties, {width, height}, report = () =>
|
|
|
67
122
|
const angle = turn(Math.atan2(height * Math.sin(radians), width * Math.cos(radians)) * 180 / Math.PI);
|
|
68
123
|
const a = angle * Math.PI / 180, span = Math.abs(Math.cos(a)) + Math.abs(Math.sin(a));
|
|
69
124
|
const stops = list(gradient['a:gsLst']?.['a:gs']).map(stop => {
|
|
70
|
-
const c =
|
|
125
|
+
const c = readBackgroundColor(stop, context), position = (Number(stop.pos) / 100000 - .5) * span + .5;
|
|
71
126
|
// Allow only native integer rounding, not a lossy clamping of arbitrary
|
|
72
127
|
// corner-to-corner gradients that OPF's fixed endpoints cannot represent.
|
|
73
128
|
if (!c || !Number.isFinite(position) || position < -.00002 || position > 1.00002) return null;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {importImageOrientation} from './image-import.js';
|
|
2
|
-
import {nativeBackgroundFill
|
|
2
|
+
import {nativeBackgroundFill} from './background.js';
|
|
3
|
+
import {importBackground} from './background-import.js';
|
|
3
4
|
import { webpToPng } from '#image-fallback';
|
|
4
5
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
5
|
-
import { composeSlide, fitText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
6
|
+
import { composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
6
7
|
import PptxGenJS from "pptxgenjs";
|
|
7
8
|
import { unzipSync, zipSync } from "fflate";
|
|
8
9
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -250,13 +251,13 @@ function readPptxZip(input) {
|
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
function parseRequiredXml(entries, path) {
|
|
254
|
+
function parseRequiredXml(entries, path, parser = xmlParser) {
|
|
254
255
|
const bytes = entries[path];
|
|
255
256
|
if (!bytes) {
|
|
256
257
|
throw new OPFPptxError("invalid-pptx", `PPTX is missing ${path}.`, { path });
|
|
257
258
|
}
|
|
258
259
|
try {
|
|
259
|
-
return
|
|
260
|
+
return parser.parse(decodeText(bytes));
|
|
260
261
|
} catch (error) {
|
|
261
262
|
throw new OPFPptxError("invalid-pptx", `PPTX XML part could not be parsed: ${path}.`, {
|
|
262
263
|
path,
|
|
@@ -371,7 +372,9 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
371
372
|
const slide = {};
|
|
372
373
|
if (slideRoot.show === "0") slide.hidden = true;
|
|
373
374
|
|
|
374
|
-
const background =
|
|
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`}));
|
|
375
378
|
if (background) slide.design = {background};
|
|
376
379
|
|
|
377
380
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
|
|
@@ -1119,16 +1122,42 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1119
1122
|
const cellPath = header ? `${path}.columns.${columnIndex}` : `${path}.rows.${rowIndex - Number(hasHeaders)}.${columnIndex}`;
|
|
1120
1123
|
const text = stringifyText(row[columnIndex]);
|
|
1121
1124
|
const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: header ? 700 : 400, italic: false, path: cellPath }, options.textMeasurement);
|
|
1122
|
-
const
|
|
1125
|
+
const rich = Array.isArray(row[columnIndex]);
|
|
1126
|
+
const fit = rich
|
|
1127
|
+
? fitRichText(row[columnIndex], cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, {style,textMeasurement:options.textMeasurement})
|
|
1128
|
+
: fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
1129
|
+
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
1130
|
+
const runs = rich ? row[columnIndex].flatMap((value, index) => {
|
|
1131
|
+
const run = typeof value === 'string' ? {text:value} : value;
|
|
1132
|
+
const fragment = fragments.find(item => item.runIndex === index);
|
|
1133
|
+
const runStyle = fragment?.style ?? resolveTextStyle({...style,fontFamily:run.fontFamily ?? style.fontFamily,fontWeight:run.bold === undefined ? style.fontWeight : run.bold ? 700 : 400,italic:run.italic ?? style.italic}, options.textMeasurement);
|
|
1134
|
+
const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : header ? 'FFFFFF' : context.colors.text;
|
|
1135
|
+
const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1136
|
+
const runOptions = {
|
|
1137
|
+
fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1138
|
+
bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
|
|
1139
|
+
underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1140
|
+
color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
1141
|
+
hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
1142
|
+
};
|
|
1143
|
+
// PptxGenJS marks every part of a newline-containing run as a paragraph
|
|
1144
|
+
// break, including its final part. Split explicitly so the next styled
|
|
1145
|
+
// run stays on that final line, and preserve empty/trailing lines.
|
|
1146
|
+
const parts = run.text.split(/\r*\n/);
|
|
1147
|
+
return parts.map((text, part) => ({text,options:{...runOptions,breakLine:part < parts.length - 1}}));
|
|
1148
|
+
}) : null;
|
|
1123
1149
|
return {
|
|
1124
1150
|
// Keep native wrapping and the original cell value: inserting measured
|
|
1125
1151
|
// soft wraps into the text would change a later import or copy operation.
|
|
1126
|
-
text,
|
|
1152
|
+
text: rich ? runs : text,
|
|
1127
1153
|
options: {
|
|
1128
1154
|
fontFace: style.fontFamily,
|
|
1129
1155
|
fontSize: fit.fontSize * 0.75,
|
|
1130
|
-
|
|
1131
|
-
|
|
1156
|
+
// PptxGenJS fills falsy run options from cell defaults. Rich runs
|
|
1157
|
+
// carry their resolved weight, so a bold header default must not turn
|
|
1158
|
+
// an explicit bold:false run back on.
|
|
1159
|
+
bold: rich ? false : style.fontWeight >= 600,
|
|
1160
|
+
italic: rich ? false : style.italic,
|
|
1132
1161
|
lineSpacing: fit.lineHeight * 0.75,
|
|
1133
1162
|
paraSpaceAfter: 0,
|
|
1134
1163
|
align: context.contentAlignment,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,21 +35,21 @@
|
|
|
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 && 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",
|
|
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 && node --check test/rich-table.mjs",
|
|
39
39
|
"test": "npm run build && node test/dependency-boundary.mjs && npm run build:browser-check",
|
|
40
40
|
"validate": "node scripts/validate-package.mjs",
|
|
41
41
|
"prepack": "npm run build",
|
|
42
42
|
"build:browser-check": "node scripts/build-browser-check.mjs"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@openpresentation/opf": "^0.
|
|
45
|
+
"@openpresentation/opf": "^0.5.0",
|
|
46
46
|
"fast-xml-parser": "^5.8.0",
|
|
47
47
|
"fflate": "^0.8.3",
|
|
48
48
|
"pptxgenjs": "4.0.1",
|
|
49
49
|
"sharp": "0.35.4"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@openpresentation/opf-render": "^0.
|
|
52
|
+
"@openpresentation/opf-render": "^0.3.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@openpresentation/opf-render": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@openpresentation/opf-render": "0.
|
|
60
|
+
"@openpresentation/opf-render": "0.3.0",
|
|
61
61
|
"esbuild": "0.28.2"
|
|
62
62
|
},
|
|
63
63
|
"imports": {
|