@openpresentation/opf-pptx 0.2.1 → 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 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
- - Imported table values are display strings: numeric/boolean/null types, rich cell formatting, whitespace and merged-cell semantics are not losslessly reconstructed.
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 (local development)
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.1.0 requires published `@openpresentation/opf@^0.4.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.1.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
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
 
@@ -121,15 +123,17 @@ Pass the same `textMeasurement` provider used by preview and pagination to `toPp
121
123
 
122
124
  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
125
 
124
- ### Native table fitting (unreleased)
126
+ ### Native table fitting
125
127
 
126
- The development exporter measures every cell with the same `textMeasurement` provider, font roles and effective nested `minFontSize` used by the SVG preview. Native table cells retain the original strings and values as text, with matching fitted sizes, line spacing, alignment, margins and row/column geometry. Uneven rows receive empty cells for missing columns. Theme border colors now use the same slot as the preview.
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
- `npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require shrinking, Roboto and Calibri-to-Carlito substitution, two canvas sizes, headers and all three alignments. PowerPoint still performs its own natural wrapping and needs the resolved fonts installed. These document-property checks do not establish native raster parity or lossless typed-cell import.
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.
131
+
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.
129
133
 
130
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.
131
135
 
132
- ## Image geometry (unreleased)
136
+ ## Image geometry
133
137
 
134
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.
135
139
 
@@ -154,7 +158,7 @@ Node conversion lazily loads the pinned open-source Sharp dependency and require
154
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.
155
159
 
156
160
 
157
- ## Native background fills (unreleased)
161
+ ## Native background fills
158
162
 
159
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.
160
164
 
@@ -165,7 +169,7 @@ Import reads supported native RGB solid/linear fills directly; it uses no hidden
165
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.
166
170
 
167
171
 
168
- ## JPEG orientation on import (unreleased)
172
+ ## JPEG orientation on import
169
173
 
170
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.
171
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 object(nodes) {
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[':@'] ?? {}), ...object(value)};
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[':@'] ?? {}), ...object(item[key])}]));
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: object(tree)});
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 importBackground(slidePath, dimensions, {part, relationships, bytes}, report) {
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(object([style]), dimensions, report, context);
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 gradient or picture crop/transform details that OPF import cannot preserve. Picture paths identify the native picture index. */
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, 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
- for (const frame of asArray(tree?.["p:graphicFrame"])) {
408
- const item = importGraphicFrame(entries, frame, slidePath, relationships);
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: tableFromXml(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,28 +1105,45 @@ function addTablePayload(slide, table, region, context, options, path) {
1110
1105
  return;
1111
1106
  }
1112
1107
 
1113
- const columnCount = Math.max(1, ...sourceRows.map(row => row.length));
1114
- const rowHeight = Math.min(54 * scale / 96, region.h / sourceRows.length);
1115
- const cellBox = {
1116
- x: 0, y: 0,
1117
- width: Math.max(scale, region.w * 96 / columnCount - 20 * scale),
1118
- height: Math.max(scale, rowHeight * 96 - 12 * scale),
1119
- };
1120
- const rows = sourceRows.map((row, rowIndex) => Array.from({ length: columnCount }, (_, columnIndex) => {
1121
- const header = hasHeaders && rowIndex === 0;
1122
- const cellPath = header ? `${path}.columns.${columnIndex}` : `${path}.rows.${rowIndex - Number(hasHeaders)}.${columnIndex}`;
1123
- const text = stringifyText(row[columnIndex]);
1124
- const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: header ? 700 : 400, italic: false, path: cellPath }, options.textMeasurement);
1125
- const fit = fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
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;
1115
+ const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
1116
+ const runs = rich ? cell.value.flatMap((value, index) => {
1117
+ const run = typeof value === 'string' ? {text:value} : value;
1118
+ const fragment = fragments.find(item => item.runIndex === index);
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);
1120
+ 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;
1121
+ const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1122
+ const runOptions = {
1123
+ fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1124
+ bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
1125
+ underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1126
+ color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1127
+ hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
1128
+ };
1129
+ // PptxGenJS marks every part of a newline-containing run as a paragraph
1130
+ // break, including its final part. Split explicitly so the next styled
1131
+ // run stays on that final line, and preserve empty/trailing lines.
1132
+ const parts = run.text.split(/\r*\n/);
1133
+ return parts.map((text, part) => ({text,options:{...runOptions,breakLine:part < parts.length - 1}}));
1134
+ }) : null;
1126
1135
  return {
1127
1136
  // Keep native wrapping and the original cell value: inserting measured
1128
1137
  // soft wraps into the text would change a later import or copy operation.
1129
- text,
1138
+ text: rich ? runs : text,
1130
1139
  options: {
1131
1140
  fontFace: style.fontFamily,
1132
1141
  fontSize: fit.fontSize * 0.75,
1133
- bold: style.fontWeight >= 600,
1134
- italic: style.italic,
1142
+ // PptxGenJS fills falsy run options from cell defaults. Rich runs
1143
+ // carry their resolved weight, so a bold header default must not turn
1144
+ // an explicit bold:false run back on.
1145
+ bold: rich ? false : style.fontWeight >= 600,
1146
+ italic: rich ? false : style.italic,
1135
1147
  lineSpacing: fit.lineHeight * 0.75,
1136
1148
  paraSpaceAfter: 0,
1137
1149
  align: context.contentAlignment,
@@ -1147,8 +1159,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1147
1159
  x: region.x,
1148
1160
  y: region.y,
1149
1161
  w: region.w,
1150
- h: rowHeight * rows.length,
1151
- rowH: rowHeight,
1162
+ h: layout.height / 96,
1163
+ rowH: layout.rows.map(row => row.box.height / 96),
1152
1164
  colW: Array(columnCount).fill(region.w / columnCount),
1153
1165
  autoPage: false,
1154
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.2.1",
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",
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.4.1",
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.2.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.2.0",
60
+ "@openpresentation/opf-render": "0.4.0",
61
61
  "esbuild": "0.28.2"
62
62
  },
63
63
  "imports": {