@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 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 (unreleased)
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, transformed/theme stop colors, non-default tile/flip geometry and stop intervals outside OPF's fixed-endpoint representation are not imported. Pass `fromPptx(bytes, {onDiagnostic: issue => ...})` to observe `unsupported-background-gradient` with a slide path.
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
+ }
@@ -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
- function readColor(node) {
42
- const c = node?.['a:srgbClr'];
43
- if (!c || !/^[\da-f]{6}$/i.test(c.val) || Object.keys(c).some(k => k !== 'val' && k !== 'a:alpha')) return null;
44
- const alpha = Number(c['a:alpha']?.val ?? 100000) / 100000;
45
- if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return null;
46
- return {hex: '#' + c.val.toUpperCase(), alpha};
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 = readColor(properties['a:solidFill']);
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) return undefined;
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 = readColor(stop), position = (Number(stop.pos) / 100000 - .5) * span + .5;
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, readNativeBackground} from './background.js';
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 xmlParser.parse(decodeText(bytes));
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 = readNativeBackground(slideRoot["p:cSld"]?.["p:bg"]?.["p:bgPr"], resolveCanvasDimensions(dimensions), diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.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 fit = fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
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
- bold: style.fontWeight >= 600,
1131
- italic: style.italic,
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.2.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.4.1",
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.2.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.2.0",
60
+ "@openpresentation/opf-render": "0.3.0",
61
61
  "esbuild": "0.28.2"
62
62
  },
63
63
  "imports": {