@openpresentation/opf-pptx 0.3.0 → 0.4.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 +11 -9
- package/dist/background-import.js +14 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +22 -36
- package/dist/table-import.js +108 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -67,7 +67,9 @@ The first importer is mechanical and schema-compatible:
|
|
|
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
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
|
-
-
|
|
70
|
+
- Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells with supported formatting become canonical `TextRun[]`; unstyled body cells stay strings. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
|
|
71
|
+
- Imported runs retain bold, italic, underline, strike, point sizes, Latin font families, solid colors/alpha, external hyperlink URLs, and superscript/subscript direction. List-level and paragraph defaults apply before run overrides; supported theme fonts/colors resolve from the archive. Field values become their cached text, and underline/strike variants and baseline magnitudes reduce to OPF booleans.
|
|
72
|
+
- Conditional table styles, merged-cell geometry, cell fills/borders/alignment, unsupported text fills/colors, and internal hyperlink actions are not fully reconstructed. `onDiagnostic` reports unsupported table style references, merges, fonts/colors/fills and links with native frame/cell paths. Table paths use the native graphic-frame and row indexes, including a header row. The shared core 0.6.0 layout sizes rows from their content and reports `text-overflow` when text cannot fit at the minimum size. These checks establish native XML conversion, not visual parity with PowerPoint.
|
|
71
73
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
72
74
|
|
|
73
75
|
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
|
|
@@ -109,11 +111,11 @@ Required first-publish setup:
|
|
|
109
111
|
|
|
110
112
|
This repo does not require an npm automation token when Trusted Publishing is configured.
|
|
111
113
|
|
|
112
|
-
## Shared dynamic composition
|
|
114
|
+
## Shared dynamic composition
|
|
113
115
|
|
|
114
116
|
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
115
117
|
|
|
116
|
-
Version 0.
|
|
118
|
+
Version 0.4.0 requires published `@openpresentation/opf@^0.6.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.4.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
117
119
|
|
|
118
120
|
For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagination` first, then pass its returned presentation to both preview and `toPptx`. Native table row sizing now follows shared reference geometry; the exporter does not add hidden table continuation slides.
|
|
119
121
|
|
|
@@ -123,15 +125,15 @@ PptxGenJS is pinned to 4.0.1. Its unused `image-size` dependency remains flagged
|
|
|
123
125
|
|
|
124
126
|
### Native table fitting
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
Version 0.4.0 imports and exports supported rich table cells and headers as editable runs, retaining resolved fonts, emphasis, color/alpha, hyperlinks, script positions and explicit line breaks. Import reads native XML, including paragraph defaults and significant whitespace; unstyled body cells remain strings. Shared core 0.6.0 layout grows rows for multiline content and fits text consistently with renderer 0.4.0 without inserting measured soft wraps. Native PowerPoint rendering remains unverified.
|
|
127
129
|
|
|
128
|
-
The
|
|
130
|
+
The 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.
|
|
129
131
|
|
|
130
|
-
`npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require
|
|
132
|
+
`npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require taller rows, 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.
|
|
131
133
|
|
|
132
134
|
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.
|
|
133
135
|
|
|
134
|
-
## Image geometry
|
|
136
|
+
## Image geometry
|
|
135
137
|
|
|
136
138
|
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.
|
|
137
139
|
|
|
@@ -156,7 +158,7 @@ Node conversion lazily loads the pinned open-source Sharp dependency and require
|
|
|
156
158
|
`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.
|
|
157
159
|
|
|
158
160
|
|
|
159
|
-
## Native background fills
|
|
161
|
+
## Native background fills
|
|
160
162
|
|
|
161
163
|
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.
|
|
162
164
|
|
|
@@ -167,7 +169,7 @@ Import reads supported native RGB solid/linear fills directly; it uses no hidden
|
|
|
167
169
|
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.
|
|
168
170
|
|
|
169
171
|
|
|
170
|
-
## JPEG orientation on import
|
|
172
|
+
## JPEG orientation on import
|
|
171
173
|
|
|
172
174
|
`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.
|
|
173
175
|
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {readNativeBackground, readBackgroundColor, colorTransforms} from './background.js';
|
|
3
3
|
|
|
4
|
-
const orderedParser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', preserveOrder: true, parseAttributeValue: false, parseTagValue: false});
|
|
4
|
+
const orderedParser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', preserveOrder: true, parseAttributeValue: false, parseTagValue: false, trimValues: false});
|
|
5
5
|
const defaultMapping = {bg1:'lt1',tx1:'dk1',bg2:'lt2',tx2:'dk2'};
|
|
6
6
|
const children = (nodes, name) => nodes?.find(node => Object.hasOwn(node, name))?.[name];
|
|
7
|
-
function
|
|
7
|
+
export function drawingObject(nodes) {
|
|
8
8
|
const result = Object.create(null);
|
|
9
9
|
for (const node of nodes ?? []) for (const [name, value] of Object.entries(node)) {
|
|
10
10
|
if (name === ':@' || name === '#text') continue;
|
|
11
|
-
const child = {...(node[':@'] ?? {}), ...
|
|
11
|
+
const child = {...(node[':@'] ?? {}), ...drawingObject(value)};
|
|
12
12
|
if (['a:srgbClr', 'a:sysClr', 'a:schemeClr'].includes(name)) {
|
|
13
13
|
child[colorTransforms] = (value ?? []).flatMap(item => Object.keys(item)
|
|
14
14
|
.filter(key => key !== ':@' && key !== '#text')
|
|
15
|
-
.map(key => [key, {...(item[':@'] ?? {}), ...
|
|
15
|
+
.map(key => [key, {...(item[':@'] ?? {}), ...drawingObject(item[key])}]));
|
|
16
16
|
}
|
|
17
17
|
if (Object.hasOwn(result, name)) result[name] = Array.isArray(result[name]) ? [...result[name], child] : [result[name], child];
|
|
18
18
|
else result[name] = child;
|
|
@@ -27,13 +27,13 @@ function parsed(bytes, parse) {
|
|
|
27
27
|
if (!bytes) return undefined;
|
|
28
28
|
if (!parsedParts.has(bytes)) {
|
|
29
29
|
const tree = parse();
|
|
30
|
-
parsedParts.set(bytes, {tree, value:
|
|
30
|
+
parsedParts.set(bytes, {tree, value: drawingObject(tree)});
|
|
31
31
|
}
|
|
32
32
|
return parsedParts.get(bytes);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Resolve only relationships inside the input archive; never fetch theme URLs.
|
|
36
|
-
export function
|
|
36
|
+
export function readSlideTheme(slidePath, {part, relationships, bytes}) {
|
|
37
37
|
const parsedPart = path => parsed(bytes(path), () => part(path, orderedParser));
|
|
38
38
|
const value = path => parsedPart(path)?.value;
|
|
39
39
|
const related = (path, type) => [...relationships(path).values()].find(rel => rel.type.endsWith('/' + type) && bytes(rel.path))?.path;
|
|
@@ -49,7 +49,7 @@ export function importBackground(slidePath, dimensions, {part, relationships, by
|
|
|
49
49
|
if (override?.['a:overrideClrMapping']) mapping = {...defaultMapping, ...override['a:overrideClrMapping']};
|
|
50
50
|
else if (override && Object.hasOwn(override, 'a:masterClrMapping')) mapping = masterMapping;
|
|
51
51
|
}
|
|
52
|
-
let colors, format, formatPath;
|
|
52
|
+
let colors, fonts, format, formatPath;
|
|
53
53
|
for (const {path} of [...chain].reverse()) {
|
|
54
54
|
for (const type of ['theme', 'themeOverride']) {
|
|
55
55
|
const themePath = related(path, type);
|
|
@@ -57,9 +57,15 @@ export function importBackground(slidePath, dimensions, {part, relationships, by
|
|
|
57
57
|
const doc = value(themePath);
|
|
58
58
|
const elements = type === 'theme' ? doc?.['a:theme']?.['a:themeElements'] : doc?.['a:themeOverride'];
|
|
59
59
|
if (elements?.['a:clrScheme']) colors = elements['a:clrScheme'];
|
|
60
|
+
if (elements?.['a:fontScheme']) fonts = elements['a:fontScheme'];
|
|
60
61
|
if (elements?.['a:fmtScheme']) {format = elements['a:fmtScheme'];formatPath = themePath;}
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
return {chain, colors, mapping, fonts, format, formatPath, parsedPart};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function importBackground(slidePath, dimensions, archive, report) {
|
|
68
|
+
const {chain, colors, mapping, format, formatPath, parsedPart} = readSlideTheme(slidePath, archive);
|
|
63
69
|
const background = chain.map(item => item.root?.['p:cSld']?.['p:bg']).find(value => value !== undefined);
|
|
64
70
|
if (!background) return undefined;
|
|
65
71
|
const unsupported = () => {
|
|
@@ -84,5 +90,5 @@ export function importBackground(slidePath, dimensions, {part, relationships, by
|
|
|
84
90
|
const style = styles?.[index < 1000 ? index - 1 : index - 1001];
|
|
85
91
|
if (!style) return unsupported();
|
|
86
92
|
context.placeholder = readBackgroundColor(reference, context);
|
|
87
|
-
return readNativeBackground(
|
|
93
|
+
return readNativeBackground(drawingObject([style]), dimensions, report, context);
|
|
88
94
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface ToPptxOptions {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export interface FromPptxOptions {
|
|
51
|
-
/** Reports native
|
|
51
|
+
/** Reports native background, picture and table details that OPF import cannot preserve. Table paths identify the native graphic-frame and row/cell indexes (including headers). */
|
|
52
52
|
onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
|
|
53
53
|
fallbackName?: string;
|
|
54
54
|
schema?: string;
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import {importTableFrames} from './table-import.js';
|
|
1
2
|
import {importImageOrientation} from './image-import.js';
|
|
2
3
|
import {nativeBackgroundFill} from './background.js';
|
|
3
4
|
import {importBackground} from './background-import.js';
|
|
4
5
|
import { webpToPng } from '#image-fallback';
|
|
5
6
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
6
|
-
import { composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
7
|
+
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
7
8
|
import PptxGenJS from "pptxgenjs";
|
|
8
9
|
import { unzipSync, zipSync } from "fflate";
|
|
9
10
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -282,6 +283,7 @@ function parseRelationships(entries, sourcePartPath) {
|
|
|
282
283
|
id: relationship.Id,
|
|
283
284
|
type: relationship.Type ?? "",
|
|
284
285
|
target: relationship.Target ?? "",
|
|
286
|
+
targetMode: relationship.TargetMode ?? "Internal",
|
|
285
287
|
path: resolveRelationshipTarget(sourcePartPath, relationship.Target ?? "")
|
|
286
288
|
});
|
|
287
289
|
}
|
|
@@ -404,8 +406,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
404
406
|
if (item) items.push(item);
|
|
405
407
|
}
|
|
406
408
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
+
const frames = asArray(tree?.["p:graphicFrame"]);
|
|
410
|
+
const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
|
|
411
|
+
? importTableFrames(slidePath, {
|
|
412
|
+
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
413
|
+
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`})) : [];
|
|
414
|
+
for (const [index, frame] of frames.entries()) {
|
|
415
|
+
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
409
416
|
if (item) items.push(item);
|
|
410
417
|
}
|
|
411
418
|
|
|
@@ -446,7 +453,7 @@ function importShape(shape, dimensions) {
|
|
|
446
453
|
};
|
|
447
454
|
}
|
|
448
455
|
|
|
449
|
-
function importGraphicFrame(entries, frame, slidePath, relationships) {
|
|
456
|
+
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
|
|
450
457
|
const bounds = shapeBounds(frame["p:xfrm"]);
|
|
451
458
|
const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
|
|
452
459
|
const graphicData = frame["a:graphic"]?.["a:graphicData"];
|
|
@@ -458,7 +465,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships) {
|
|
|
458
465
|
name,
|
|
459
466
|
payload: {
|
|
460
467
|
type: "table",
|
|
461
|
-
table:
|
|
468
|
+
table: importedTable
|
|
462
469
|
}
|
|
463
470
|
};
|
|
464
471
|
}
|
|
@@ -632,18 +639,6 @@ function payloadFromSlideItem(item) {
|
|
|
632
639
|
return null;
|
|
633
640
|
}
|
|
634
641
|
|
|
635
|
-
function tableFromXml(table) {
|
|
636
|
-
const rows = asArray(table["a:tr"])
|
|
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 };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
642
|
function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
648
643
|
const relationship = relationships.get(relId);
|
|
649
644
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
@@ -1110,24 +1105,15 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1110
1105
|
return;
|
|
1111
1106
|
}
|
|
1112
1107
|
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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 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));
|
|
1108
|
+
const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
|
|
1109
|
+
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path
|
|
1110
|
+
});
|
|
1111
|
+
const columnCount = layout.columnCount;
|
|
1112
|
+
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
1113
|
+
const {header,rich,fit} = cell;
|
|
1114
|
+
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
1129
1115
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
1130
|
-
const runs = rich ?
|
|
1116
|
+
const runs = rich ? cell.value.flatMap((value, index) => {
|
|
1131
1117
|
const run = typeof value === 'string' ? {text:value} : value;
|
|
1132
1118
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1133
1119
|
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);
|
|
@@ -1173,8 +1159,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1173
1159
|
x: region.x,
|
|
1174
1160
|
y: region.y,
|
|
1175
1161
|
w: region.w,
|
|
1176
|
-
h:
|
|
1177
|
-
rowH:
|
|
1162
|
+
h: layout.height / 96,
|
|
1163
|
+
rowH: layout.rows.map(row => row.box.height / 96),
|
|
1178
1164
|
colW: Array(columnCount).fill(region.w / columnCount),
|
|
1179
1165
|
autoPage: false,
|
|
1180
1166
|
fontFace: context.fonts.body,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {drawingObject, readSlideTheme} from './background-import.js';
|
|
2
|
+
import {readBackgroundColor} from './background.js';
|
|
3
|
+
|
|
4
|
+
const nodes = (tree, tag) => (tree ?? []).filter(node => Object.hasOwn(node, tag));
|
|
5
|
+
const child = (tree, tag) => nodes(tree, tag)[0]?.[tag];
|
|
6
|
+
const attrs = (tree, tag) => nodes(tree, tag)[0]?.[':@'] ?? {};
|
|
7
|
+
const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
|
|
8
|
+
const boolean = value => value === '1' || value === 'true' ? true : value === '0' || value === 'false' ? false : undefined;
|
|
9
|
+
|
|
10
|
+
function runStyle(properties, context, relationships, report) {
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const [native, opf] of [['b','bold'], ['i','italic']]) {
|
|
13
|
+
const value = boolean(properties[native]);
|
|
14
|
+
if (value !== undefined) result[opf] = value;
|
|
15
|
+
}
|
|
16
|
+
for (const [native, opf] of [['u','underline'], ['strike','strikethrough']]) {
|
|
17
|
+
if (properties[native] !== undefined) result[opf] = properties[native] !== 'none' && properties[native] !== 'noStrike';
|
|
18
|
+
}
|
|
19
|
+
const size = Number(properties.sz);
|
|
20
|
+
if (Number.isFinite(size) && size > 0) result.fontSize = size / 100;
|
|
21
|
+
const baseline = Number(properties.baseline);
|
|
22
|
+
if (Number.isFinite(baseline)) {
|
|
23
|
+
result.superscript = baseline > 0;
|
|
24
|
+
result.subscript = baseline < 0;
|
|
25
|
+
}
|
|
26
|
+
const font = properties['a:latin']?.typeface;
|
|
27
|
+
if (font) {
|
|
28
|
+
const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
|
|
29
|
+
const family = match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
|
|
30
|
+
if (family) result.fontFamily = family;
|
|
31
|
+
else report('unsupported-table-font', 'The theme font reference could not be resolved from this archive.');
|
|
32
|
+
}
|
|
33
|
+
if (properties['a:solidFill']) {
|
|
34
|
+
const color = readBackgroundColor(properties['a:solidFill'], context);
|
|
35
|
+
if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
|
|
36
|
+
else report('unsupported-table-text-color', 'The native text color or its transforms could not be resolved.');
|
|
37
|
+
} else if (properties['a:noFill']) {
|
|
38
|
+
result.color = '#00000000';
|
|
39
|
+
}
|
|
40
|
+
if (['a:gradFill','a:blipFill','a:pattFill','a:grpFill'].some(key => properties[key])) report('unsupported-table-text-fill', 'Only solid native text fills are represented by OPF runs.');
|
|
41
|
+
const hyperlink = properties['a:hlinkClick'];
|
|
42
|
+
if (hyperlink) {
|
|
43
|
+
const relationship = relationships.get(hyperlink['r:id']);
|
|
44
|
+
if (relationship?.type.endsWith('/hyperlink') && relationship.targetMode === 'External' && relationship.target && !hyperlink.action) result.link = relationship.target;
|
|
45
|
+
else report('unsupported-table-link', 'The native hyperlink action or relationship cannot be represented as a URL.');
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Merge at the native property level before conversion: an explicit normal run
|
|
51
|
+
// overrides a bold paragraph default, and a new fill replaces the inherited fill.
|
|
52
|
+
function mergeProperties(...levels) {
|
|
53
|
+
const result = {};
|
|
54
|
+
const fills = ['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill'];
|
|
55
|
+
for (const level of levels) {
|
|
56
|
+
if (!level) continue;
|
|
57
|
+
if (fills.some(key => Object.hasOwn(level, key))) for (const key of fills) delete result[key];
|
|
58
|
+
Object.assign(result, level);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cellText(body, context, relationships, report, header) {
|
|
64
|
+
const listStyle = drawingObject(child(body, 'a:lstStyle'));
|
|
65
|
+
const paragraphs = nodes(body, 'a:p');
|
|
66
|
+
const runs = [];
|
|
67
|
+
const append = (value, style = {}) => {
|
|
68
|
+
if (!value) return;
|
|
69
|
+
runs.push(Object.keys(style).length ? {text:value, ...style} : value);
|
|
70
|
+
};
|
|
71
|
+
paragraphs.forEach((paragraph, index) => {
|
|
72
|
+
const content = paragraph['a:p'];
|
|
73
|
+
if (index) append('\n');
|
|
74
|
+
const pPr = drawingObject(content)['a:pPr'] ?? {};
|
|
75
|
+
const level = Number(pPr.lvl ?? 0) + 1;
|
|
76
|
+
const defaults = mergeProperties(header ? {b:'0'} : undefined, listStyle['a:defPPr']?.['a:defRPr'], listStyle[`a:lvl${level}pPr`]?.['a:defRPr'], pPr['a:defRPr']);
|
|
77
|
+
for (const node of content) {
|
|
78
|
+
const tag = ['a:r', 'a:fld', 'a:br'].find(tag => Object.hasOwn(node, tag));
|
|
79
|
+
if (!tag) continue;
|
|
80
|
+
const properties = mergeProperties(defaults, drawingObject(node[tag])['a:rPr']);
|
|
81
|
+
append(tag === 'a:br' ? '\n' : text(child(node[tag], 'a:t')), runStyle(properties, context, relationships, report));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return runs.some(run => typeof run !== 'string') ? runs : runs.join('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Use the same direct graphic-frame ordering as the slide collector. The ordered
|
|
88
|
+
// reader retains interleaved runs, fields, breaks, empty paragraphs and spaces.
|
|
89
|
+
export function importTableFrames(slidePath, archive, relationships, report) {
|
|
90
|
+
const context = readSlideTheme(slidePath, archive);
|
|
91
|
+
const root = child(context.parsedPart(slidePath)?.tree, 'p:sld');
|
|
92
|
+
const tree = child(child(root, 'p:cSld'), 'p:spTree');
|
|
93
|
+
return nodes(tree, 'p:graphicFrame').map((frame, frameIndex) => {
|
|
94
|
+
const table = child(child(child(frame['p:graphicFrame'], 'a:graphic'), 'a:graphicData'), 'a:tbl');
|
|
95
|
+
if (!table) return undefined;
|
|
96
|
+
const props = child(table, 'a:tblPr');
|
|
97
|
+
if (child(props, 'a:tableStyleId') || child(props, 'a:tableStyle')) report(frameIndex, '', 'unsupported-table-style', 'Conditional native table styles are not applied; direct cell and paragraph text formatting is retained.');
|
|
98
|
+
const headers = boolean(attrs(table, 'a:tblPr').firstRow) === true;
|
|
99
|
+
const rows = nodes(table, 'a:tr').map((row, rowIndex) => nodes(row['a:tr'], 'a:tc').map((cell, columnIndex) => {
|
|
100
|
+
const cellPath = `rows.${rowIndex}.${columnIndex}`;
|
|
101
|
+
const emit = (code, message) => report(frameIndex, cellPath, code, message);
|
|
102
|
+
const geometry = cell[':@'] ?? {};
|
|
103
|
+
if (['gridSpan','rowSpan'].some(key => Number(geometry[key] ?? 1) > 1) || ['hMerge','vMerge'].some(key => boolean(geometry[key]) === true)) emit('unsupported-table-merge', 'Merged-cell geometry is not represented; the native cell text is retained.');
|
|
104
|
+
return cellText(child(cell['a:tc'], 'a:txBody'), context, relationships, emit, headers && rowIndex === 0);
|
|
105
|
+
}));
|
|
106
|
+
return headers && rows.length ? {columns:rows[0], rows:rows.slice(1)} : {rows};
|
|
107
|
+
});
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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 && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.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 && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.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.6.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.4.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.4.0",
|
|
61
61
|
"esbuild": "0.28.2"
|
|
62
62
|
},
|
|
63
63
|
"imports": {
|