@openpresentation/opf-pptx 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPENDENCY-NOTES.md +23 -0
- package/README.md +24 -2
- package/dist/index.js +68 -7
- package/dist/table-border-import.js +73 -0
- package/dist/table-cell-import.js +138 -0
- package/dist/table-import.js +116 -12
- package/package.json +16 -9
- package/vendor/pptxgenjs/LICENSE +21 -0
- package/vendor/pptxgenjs/UPSTREAM.json +19 -0
- package/vendor/pptxgenjs/package.json +8 -0
- package/vendor/pptxgenjs/pptxgen.es.js +7368 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# PptxGenJS runtime provenance
|
|
2
|
+
|
|
3
|
+
OPF PPTX 0.5.1 ships the exact, unmodified PptxGenJS 4.0.1 ESM distribution in `vendor/pptxgenjs`, with its MIT license, upstream archive integrity and per-file SHA-256 hashes. Its actual JSZip dependency is declared directly. Ordinary npm installations therefore omit the unused image-size parser without requiring consumer overrides. This removes the affected dependency; it does not patch the parser. Published OPF PPTX 0.5.0 retains the older graph.
|
|
4
|
+
|
|
5
|
+
The inspected PptxGenJS 4.0.1 ESM/CommonJS distributions do not import `image-size`. The only sizing-module reference is in a commented, unused helper. OPF supplies image geometry itself. The image-fit follow-up reads bounded PNG/JPEG/GIF/WebP dimension headers and inline JPEG EXIF orientation; it does not load image-size or decode pixels.
|
|
6
|
+
|
|
7
|
+
`npm test` requires image-size to be absent, then blocks both ESM and CommonJS loading before running the full model suite and checking image-byte preservation for data URIs, local paths and host-resolved bytes. Browser bundling retains upstream's Node built-in exclusions through the vendor directory's package metadata. This is not a guarantee about every upstream PptxGenJS API or future version.
|
|
8
|
+
|
|
9
|
+
Every build checks the vendored code and license against `UPSTREAM.json`. `node scripts/vendor-pptxgenjs.mjs` is an explicit maintainer command that fetches the pinned official npm archive, verifies its SHA-512 integrity and reads only the named distribution/license entries. It is never run by builds or installations. Review the upstream source, actual imports, license and audit before changing versions; retain tests and reassess [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). Do not accept npm's incompatible PptxGenJS downgrade suggestion.
|
|
10
|
+
|
|
11
|
+
## Compatible raster decoding
|
|
12
|
+
|
|
13
|
+
Sharp 0.35.4 (Apache-2.0) is pinned for Node-only WebP-to-PNG conversion and loads lazily only when conversion is needed. Its platform packages include libvips and codec licenses; retain the upstream license files distributed by npm. Node 20.9+ and normal optional-platform dependency installation are required. Browser builds use the browser's decoder/canvas and a conditional package import; a bundling check rejects Sharp or the Node adapter in browser output. Ordinary/preserved image exports are tested with Sharp loading blocked.
|
|
14
|
+
|
|
15
|
+
The candidate's complete npm audit reports no known vulnerabilities in the installed graph. npm does not audit vendored source as an installed PptxGenJS package: maintainers must also review upstream PptxGenJS advisories and releases when updating or auditing the vendor directory. The browser bundler esbuild 0.28.2 and Playwright are development-only. No hosted service, paid API or telemetry was added.
|
|
16
|
+
|
|
17
|
+
## Repeatable patch comparison
|
|
18
|
+
|
|
19
|
+
After `npm ci`, run `npm test` and `npm run test:packed`. The packed check installs the actual tarball into a fresh temporary consumer, audits its complete graph, checks shipped upstream hashes/license and exercises editable table export/reimport. It does not use consumer overrides.
|
|
20
|
+
|
|
21
|
+
Run `npm run test:browser` for real browser WebP pixel preservation and rich, conditional and merged/styled table import/preview/export/reimport checks. Local Windows runs use installed Microsoft Edge; other environments first run `npx playwright install --with-deps chromium`. CI and release jobs run the Chromium checks on their built candidate. These assertions cover the named fixtures, not arbitrary Office files.
|
|
22
|
+
|
|
23
|
+
For a comparison against the preceding release, create a separate empty directory, run `npm init -y` there, then `npm install --ignore-scripts --no-audit --no-fund @openpresentation/opf-pptx@0.5.0`. From this repository run `npm run test:compare-published -- <absolute-consumer-directory> <report.json>`. The script verifies the reference version and registry lockfile integrity, then compares every installed core example's PPTX bytes. The older reference deliberately contains the dependency being removed; it is only a comparison input. Explicit replacement images and fallback fonts make the comparison reproducible. Matching bytes do not establish original-asset fidelity or native viewer equivalence.
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ 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
|
-
- Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells
|
|
70
|
+
- Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells return canonical `{value, style}` objects; `value` retains scalar text or supported rich runs. Covered merge positions are `null`. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
|
|
71
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
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.
|
|
73
73
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
@@ -121,7 +121,7 @@ For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagin
|
|
|
121
121
|
|
|
122
122
|
Pass the same `textMeasurement` provider used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks and resolved font family in editable PowerPoint shapes. Font binaries are not yet embedded in PPTX; native viewers still need the resolved font installed.
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
Since 0.5.1, the exact PptxGenJS 4.0.1 ESM distribution is shipped with its MIT license and verified upstream hashes. Its unused `image-size` dependency is not installed; JSZip is declared directly. See [dependency provenance and regression coverage](DEPENDENCY-NOTES.md). The published 0.5.0 package retains the older dependency graph.
|
|
125
125
|
|
|
126
126
|
### Native table fitting
|
|
127
127
|
|
|
@@ -192,3 +192,25 @@ Native dimensions retain full precision through import. Premature six-decimal in
|
|
|
192
192
|
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.
|
|
193
193
|
|
|
194
194
|
Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
## Conditional table text styles (since 0.5.0)
|
|
198
|
+
|
|
199
|
+
The importer resolves referenced table styles through the archive's presentation relationship, including custom part paths, and accepts inline definitions. It applies whole-table, alternating row/column, edge and corner text styles using [Microsoft's DrawingML precedence](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/90de4085-5fbf-47a7-bc37-d59509481d0a). Direct list-level, paragraph and run properties override the inherited style. Supported character properties are bold/italic, explicit or major/minor-theme Latin fonts, and the existing RGB/system/theme color and alpha transforms. Font-reference placeholder colors resolve after style inheritance. The table-style list's insertion default does not silently restyle existing tables without a reference.
|
|
200
|
+
|
|
201
|
+
Version 0.5.0 adds conditional table styles. Missing definitions (including built-in Office IDs whose definitions are absent from the archive) produce `unsupported-table-style`; no style data is fetched. Unsupported effects and table backgrounds produce `unsupported-table-cell-style`. Supported conditional solid fills, borders and their theme references use the same precedence as character styles. Native right-to-left geometry reports `unsupported-table-direction` and retains source cell order. Script-specific font selection, unsupported border geometry/color models and native PowerPoint raster parity remain separate work. Native re-export preserves effective character formatting but does not reconstruct the original style reference or redundant explicit normal flags.
|
|
202
|
+
|
|
203
|
+
`test/table-styles.mjs` builds independent native XML fixtures covering band offsets, overlapping first/last flags, all four corners, theme and explicit fonts, placeholder alpha, inheritance/defaults, direct resets, absent/external definitions and repeated conversion. The checked-in `test/fixtures/table-styles/conditional.pptx` is project-authored test data, not a captured native-viewer reference. After `npm run build:browser-check`, serve this repository and open `/artifacts/native-table-styles/browser/index.html` for 11 browser import, preview-trace and re-export checks.
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
## Styled and merged table cells (since 0.5.0)
|
|
207
|
+
|
|
208
|
+
The importer returns canonical `{value, style}` cell objects. Values retain supported rich runs, and styles retain direct solid fills/alpha, individual borders, horizontal/vertical alignment and reference-pixel padding. Rectangular native merges use `rowSpan`/`colSpan` anchors with explicit `null` at covered grid positions. Numeric source types cannot be recovered from displayed native text.
|
|
209
|
+
|
|
210
|
+
Import validates native continuation flags, bounds and covered text before applying merges. A malformed merge falls back to separate styled cells and reports `unsupported-table-merge`, retaining every source cell's text. If a valid merge crosses a flagged first row, the table keeps that row in its body with explicit formatting and reports `table-header-in-body`; OPF repeated headers cannot extend into body rows. Mixed/justified paragraph alignment, vertical text, unsupported fills/lines and 3D/diagonal effects remain explicit diagnostics. Unequal native column widths still need canonical representation.
|
|
211
|
+
|
|
212
|
+
Conditional borders keep the whole-table outer frame separate from interior horizontal/vertical lines. Row/column bands, edges and corners inherit line properties before direct cell overrides. Archive-local line references resolve theme placeholder colors and alpha. Missing references report `unsupported-table-border` unless an invisible or complete direct line masks them. A merged anchor uses its full span to select outer edges; differing native continuation border segments report `unsupported-table-merge-border` and retain the anchor border. `test/table-border-styles.mjs` covers these cases with native XML fixtures and repeated conversion.
|
|
213
|
+
|
|
214
|
+
Export consumes the same core geometry as SVG. It writes native merged cells and corrects PptxGenJS's padding-unit heuristic and missing dotted/transparent-border options in generated XML. Rich runs retain their own resolved opacity, including opaque overrides within a translucent cell. Tests inspect actual native XML and repeated conversions; native PowerPoint raster parity is not established.
|
|
215
|
+
|
|
216
|
+
Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `npm run test:styled-table` runs export/import regressions. For local browser verification, set `OPF_CORE_ROOT` to the core repository and `OPF_RENDER_ROOT` to the coordinated renderer worktree when running `npm run build:browser-check`. Serve the repository and open `/artifacts/native-styled-table-import/browser/index.html`. Omit those variables to exercise installed package dependencies.
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {importBackground} from './background-import.js';
|
|
|
5
5
|
import { webpToPng } from '#image-fallback';
|
|
6
6
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
7
7
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
8
|
-
import PptxGenJS from "pptxgenjs";
|
|
8
|
+
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
9
9
|
import { unzipSync, zipSync } from "fflate";
|
|
10
10
|
import { XMLParser } from "fast-xml-parser";
|
|
11
11
|
import {
|
|
@@ -167,6 +167,7 @@ export async function toPptx(input, options = {}) {
|
|
|
167
167
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
168
168
|
context.listMarkers = new Map();
|
|
169
169
|
context.tableHeaders = new Map();
|
|
170
|
+
context.tableCells = new Map();
|
|
170
171
|
context.imagePlacements = new Map();
|
|
171
172
|
context.backgroundFills = new Map();
|
|
172
173
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
@@ -410,7 +411,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
410
411
|
const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
|
|
411
412
|
? importTableFrames(slidePath, {
|
|
412
413
|
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
|
+
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
|
|
414
415
|
for (const [index, frame] of frames.entries()) {
|
|
415
416
|
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
416
417
|
if (item) items.push(item);
|
|
@@ -1111,13 +1112,17 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1111
1112
|
const columnCount = layout.columnCount;
|
|
1112
1113
|
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
1113
1114
|
const {header,rich,fit} = cell;
|
|
1115
|
+
const cellStyle = cell.style ?? {};
|
|
1116
|
+
const baseColor = (cellStyle.color ?? (header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
|
|
1117
|
+
const baseFill = (cellStyle.fill ?? (header ? '#' + context.colors.accent : '#' + context.colors.surface)).replace(/^#/, '');
|
|
1118
|
+
const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
|
|
1114
1119
|
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
1115
1120
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
1116
1121
|
const runs = rich ? cell.value.flatMap((value, index) => {
|
|
1117
1122
|
const run = typeof value === 'string' ? {text:value} : value;
|
|
1118
1123
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1119
1124
|
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) :
|
|
1125
|
+
const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
|
|
1121
1126
|
const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1122
1127
|
const runOptions = {
|
|
1123
1128
|
fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
@@ -1146,14 +1151,21 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1146
1151
|
italic: rich ? false : style.italic,
|
|
1147
1152
|
lineSpacing: fit.lineHeight * 0.75,
|
|
1148
1153
|
paraSpaceAfter: 0,
|
|
1149
|
-
align: context.contentAlignment,
|
|
1150
|
-
|
|
1151
|
-
|
|
1154
|
+
align: cellStyle.align ?? context.contentAlignment,
|
|
1155
|
+
valign: cellStyle.verticalAlign ?? 'top',
|
|
1156
|
+
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
1157
|
+
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
1158
|
+
color: normalizeHex(baseColor),
|
|
1159
|
+
// Rich runs carry resolved alpha. A translucent cell default would
|
|
1160
|
+
// overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
|
|
1161
|
+
...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
|
|
1162
|
+
fill: { color: normalizeHex(baseFill), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
|
|
1152
1163
|
},
|
|
1153
1164
|
};
|
|
1154
1165
|
}));
|
|
1155
1166
|
const objectName = `OPF table ${context.tableHeaders.size + 1}`;
|
|
1156
1167
|
context.tableHeaders.set(objectName, hasHeaders);
|
|
1168
|
+
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, defaultBorder:{color:"#"+context.colors.border,width:1/scale}});
|
|
1157
1169
|
slide.addTable(rows, {
|
|
1158
1170
|
objectName,
|
|
1159
1171
|
x: region.x,
|
|
@@ -1629,7 +1641,56 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1629
1641
|
xml = xml.replace(/<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g, frame => {
|
|
1630
1642
|
const name = frame.match(/name="(OPF table \d+)"/)?.[1];
|
|
1631
1643
|
if (!context.tableHeaders.has(name)) return frame;
|
|
1632
|
-
|
|
1644
|
+
frame = frame.replace('<a:tblPr/>', `<a:tblPr firstRow="${context.tableHeaders.get(name) ? 1 : 0}"/>`);
|
|
1645
|
+
const table = context.tableCells.get(name);
|
|
1646
|
+
if (!table) return frame;
|
|
1647
|
+
const anchors = table.layout.rows.flatMap(row => row.cells);
|
|
1648
|
+
const ownerAt = (row,column) => anchors.find(anchor => row >= anchor.row && row < anchor.row + anchor.rowSpan && column >= anchor.column && column < anchor.column + anchor.colSpan);
|
|
1649
|
+
let rowIndex = 0;
|
|
1650
|
+
return frame.replace(/<a:tr\b[^>]*>[\s\S]*?<\/a:tr>/g, rowXml => {
|
|
1651
|
+
const currentRow = rowIndex++;
|
|
1652
|
+
const cells = table.layout.rows[currentRow].cells;
|
|
1653
|
+
let column = 0;
|
|
1654
|
+
return rowXml.replace(/<a:tc\b[^>]*>[\s\S]*?<\/a:tc>/g, cellXml => {
|
|
1655
|
+
const currentColumn = column++;
|
|
1656
|
+
const cell = cells.find(cell => cell.column === currentColumn);
|
|
1657
|
+
const owner = cell ?? ownerAt(currentRow,currentColumn);
|
|
1658
|
+
if (!owner) return cellXml;
|
|
1659
|
+
// PowerPoint reads each physical continuation's perimeter border.
|
|
1660
|
+
// Anchor-only styling truncates dashes and restores hidden segments.
|
|
1661
|
+
const perimeter = {left:currentColumn === owner.column, right:currentColumn === owner.column + owner.colSpan - 1,
|
|
1662
|
+
top:currentRow === owner.row, bottom:currentRow === owner.row + owner.rowSpan - 1};
|
|
1663
|
+
const style = cell ? {...cell.style} : {borders:Object.fromEntries(Object.keys(perimeter).map(edge => [edge,perimeter[edge] ? owner.style?.borders?.[edge] ?? table.defaultBorder : {color:'#000000',width:0}]))};
|
|
1664
|
+
style.borders = {...style.borders};
|
|
1665
|
+
// Native shared-edge precedence can let an implicit neighbor cover
|
|
1666
|
+
// an explicit merge border. Give both physical sides that border.
|
|
1667
|
+
for (const [edge,opposite,dr,dc] of [['left','right',0,-1],['right','left',0,1],['top','bottom',-1,0],['bottom','top',1,0]]) {
|
|
1668
|
+
if (!perimeter[edge] || style.borders[edge] || owner.rowSpan > 1 || owner.colSpan > 1) continue;
|
|
1669
|
+
const neighbor = ownerAt(currentRow+dr,currentColumn+dc);
|
|
1670
|
+
if (neighbor?.style?.borders?.[opposite]) style.borders[edge] = neighbor.style.borders[opposite];
|
|
1671
|
+
}
|
|
1672
|
+
cellXml = cellXml.replace(/<a:tcPr\b([^>]*)\/>/, '<a:tcPr$1></a:tcPr>');
|
|
1673
|
+
return cellXml.replace(/<a:tcPr\b([^>]*)>([\s\S]*?)<\/a:tcPr>/, (properties, attributes, contents) => {
|
|
1674
|
+
if (style.padding) {
|
|
1675
|
+
const padding = {top:8, right:10, bottom:4, left:10, ...style.padding};
|
|
1676
|
+
for (const [edge, key] of [['top','marT'],['right','marR'],['bottom','marB'],['left','marL']]) {
|
|
1677
|
+
attributes = attributes.replace(new RegExp(` ${key}="[^"]*"`), '');
|
|
1678
|
+
attributes += ` ${key}="${Math.round(padding[edge] * table.scale * 9525)}"`;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
|
|
1682
|
+
const border = style.borders?.[edge];
|
|
1683
|
+
if (!border) continue;
|
|
1684
|
+
const fill = border.width === 0 ? '<a:noFill/>' : nativeBackgroundFill({type:'solid',color:border.color},{width:1,height:1});
|
|
1685
|
+
const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
|
|
1686
|
+
const line = `<a:${native} w="${Math.round(border.width * table.scale * 9525)}" cap="flat" cmpd="sng" algn="ctr">${fill}<a:prstDash val="${dash}"/></a:${native}>`;
|
|
1687
|
+
const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
|
|
1688
|
+
contents = existing.test(contents) ? contents.replace(existing, line) : contents + line;
|
|
1689
|
+
}
|
|
1690
|
+
return `<a:tcPr${attributes}>${contents}</a:tcPr>`;
|
|
1691
|
+
});
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1633
1694
|
});
|
|
1634
1695
|
// Image data is already resolved and embedded by PptxGenJS. Read those
|
|
1635
1696
|
// exact bytes instead of fetching or resolving the source a second time.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {readBackgroundColor} from './background.js';
|
|
2
|
+
|
|
3
|
+
const has=(value,key)=>Object.hasOwn(value??{},key);
|
|
4
|
+
const fills=['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill'];
|
|
5
|
+
|
|
6
|
+
export function mergeNativeLine(...levels){
|
|
7
|
+
const result={};
|
|
8
|
+
for(const level of levels){
|
|
9
|
+
if(!level)continue;
|
|
10
|
+
if(fills.some(key=>has(level,key)))for(const key of fills)delete result[key];
|
|
11
|
+
if(has(level,'a:prstDash')||has(level,'a:custDash')){delete result['a:prstDash'];delete result['a:custDash'];}
|
|
12
|
+
// A complete direct line or an invisible line masks an unavailable theme
|
|
13
|
+
// reference; partial overrides still need the missing inherited properties.
|
|
14
|
+
if(has(level,'a:noFill')||Number(level.w)===0||(has(level,'w')&&fills.some(key=>has(level,key))&&has(level,'a:prstDash')))delete result._opfUnresolvedTableLine;
|
|
15
|
+
Object.assign(result,level);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nativeStyleLine(border,context){
|
|
21
|
+
if(border['a:ln'])return border['a:ln'];
|
|
22
|
+
const reference=border['a:lnRef'];
|
|
23
|
+
if(!reference)return undefined;
|
|
24
|
+
const unresolved={_opfUnresolvedTableLine:true};
|
|
25
|
+
if(!/^\d+$/.test(reference.idx??''))return unresolved;
|
|
26
|
+
const index=Number(reference.idx);
|
|
27
|
+
if(index===0)return undefined;
|
|
28
|
+
const lines=context.format?.['a:lnStyleLst']?.['a:ln'];
|
|
29
|
+
const selected=(Array.isArray(lines)?lines:lines?[lines]:[])[index-1];
|
|
30
|
+
if(!selected)return unresolved;
|
|
31
|
+
const line={...selected};
|
|
32
|
+
if(line['a:solidFill']){
|
|
33
|
+
const color=readBackgroundColor(line['a:solidFill'],{...context,placeholder:readBackgroundColor(reference,context)});
|
|
34
|
+
if(!color)return {...line,...unresolved};
|
|
35
|
+
line['a:solidFill']={'a:srgbClr':{val:color.hex.slice(1),'a:alpha':{val:String(Math.round(color.alpha*100000))}}};
|
|
36
|
+
}
|
|
37
|
+
return line;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nativeCellExtent(row,column,rowCount,columnCount,geometry={}){
|
|
41
|
+
const span=(value,remaining)=>{const n=Number(value??1);return Number.isSafeInteger(n)&&n>=1&&n<=remaining?n:1;};
|
|
42
|
+
return {top:row===0,left:column===0,bottom:row+span(geometry.rowSpan,rowCount-row)===rowCount,right:column+span(geometry.gridSpan,columnCount-column)===columnCount};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Keep named edge and interior properties distinct until inheritance finishes.
|
|
46
|
+
* wholeTbl edges belong to its outer frame; conditional explicit edges belong
|
|
47
|
+
* to the selected cell. Interior lines apply only away from the outer frame. */
|
|
48
|
+
export function inheritNativeBorders(previous,decoration,name,extent,context){
|
|
49
|
+
const result={...previous},borders=decoration['a:tcBdr']??{};
|
|
50
|
+
for(const key of ['left','right','top','bottom','insideH','insideV','tl2br','tr2bl']){
|
|
51
|
+
const border=borders['a:'+key];
|
|
52
|
+
if(!border || name==='wholeTbl'&&has(extent,key)&&!extent[key])continue;
|
|
53
|
+
const line=nativeStyleLine(border,context);
|
|
54
|
+
if(line!==undefined)result[key]=mergeNativeLine(result[key],line);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nativeBorderProperties(borders,extent){
|
|
60
|
+
const result={};
|
|
61
|
+
for(const [edge,key,inside] of [['left','a:lnL','insideV'],['right','a:lnR','insideV'],['top','a:lnT','insideH'],['bottom','a:lnB','insideH']]){
|
|
62
|
+
const base=extent[edge]?undefined:borders[inside],line=borders[edge];
|
|
63
|
+
if(base||line)result[key]=mergeNativeLine(base,line);
|
|
64
|
+
}
|
|
65
|
+
for(const [edge,key] of [['tl2br','a:lnTlToBr'],['tr2bl','a:lnBlToTr']])if(borders[edge])result[key]=borders[edge];
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function mergeCellBorderOverrides(inherited,direct,merged){
|
|
70
|
+
const result={...merged};
|
|
71
|
+
for(const key of ['a:lnL','a:lnR','a:lnT','a:lnB','a:lnTlToBr','a:lnBlToTr'])if(inherited[key]&&direct[key])result[key]=mergeNativeLine(inherited[key],direct[key]);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {drawingObject} from './background-import.js';
|
|
2
|
+
import {readBackgroundColor} from './background.js';
|
|
3
|
+
|
|
4
|
+
const has = (value, key) => Object.hasOwn(value ?? {}, key);
|
|
5
|
+
const nodes = (tree, tag) => (tree ?? []).filter(node => has(node, tag));
|
|
6
|
+
const child = (tree, tag) => nodes(tree, tag)[0]?.[tag];
|
|
7
|
+
const truth = value => ['1','true','on'].includes(value);
|
|
8
|
+
const text = value => Array.isArray(value) ? value.map(run => typeof run === 'string' ? run : run.text).join('') : value ?? '';
|
|
9
|
+
const hex = color => color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2,'0').toUpperCase() : '');
|
|
10
|
+
|
|
11
|
+
// Preserve ordered fill-style indexes: grouping XML tags would reorder mixed
|
|
12
|
+
// solid/gradient/image entries. Resolve phClr against this reference only.
|
|
13
|
+
export function nativeStyleFill(style, context) {
|
|
14
|
+
if (style['a:fill']) return style['a:fill'];
|
|
15
|
+
const reference=style['a:fillRef'];
|
|
16
|
+
if (!reference) return {};
|
|
17
|
+
const unresolved={_opfUnresolvedTableFill:true};
|
|
18
|
+
if (!/^\d+$/.test(reference.idx ?? '')) return unresolved;
|
|
19
|
+
const index=Number(reference.idx);
|
|
20
|
+
if (index===0 || index===1000) return {'a:noFill':{}};
|
|
21
|
+
const tree=context.formatPath && context.parsedPart(context.formatPath)?.tree;
|
|
22
|
+
const theme=child(tree,'a:theme'),elements=theme?child(theme,'a:themeElements'):child(tree,'a:themeOverride');
|
|
23
|
+
const format=child(elements,'a:fmtScheme');
|
|
24
|
+
const entries=child(format,index<1000?'a:fillStyleLst':'a:bgFillStyleLst')?.filter(node=>Object.keys(node).some(key=>key!=='#text'&&key!==':@'));
|
|
25
|
+
const entry=entries?.[index<1000?index-1:index-1001];
|
|
26
|
+
if (!entry) return unresolved;
|
|
27
|
+
const properties=drawingObject([entry]);
|
|
28
|
+
if (properties['a:solidFill']) {
|
|
29
|
+
const color=readBackgroundColor(properties['a:solidFill'],{...context,placeholder:readBackgroundColor(reference,context)});
|
|
30
|
+
if (!color) return unresolved;
|
|
31
|
+
return {'a:solidFill':{'a:srgbClr':{val:color.hex.slice(1),'a:alpha':{val:String(Math.round(color.alpha*100000))}}}};
|
|
32
|
+
}
|
|
33
|
+
return properties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function solid(properties, context, report, kind) {
|
|
37
|
+
if (has(properties,'a:noFill')) return '#00000000';
|
|
38
|
+
if (properties['a:solidFill']) {
|
|
39
|
+
const color = readBackgroundColor(properties['a:solidFill'], context);
|
|
40
|
+
if (color) return hex(color);
|
|
41
|
+
report(`unsupported-table-${kind}`, 'The native solid color or its transforms could not be resolved.');
|
|
42
|
+
} else if (['a:gradFill','a:blipFill','a:pattFill','a:grpFill'].some(key => has(properties,key))) {
|
|
43
|
+
report(`unsupported-table-${kind}`, 'Only solid cell fills and border colors are represented by OPF table styles.');
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function nativeCellStyle(properties, body, context, scale, report) {
|
|
49
|
+
const style = {fill:'#00000000', align:'left', verticalAlign:'top', padding:{}, borders:{}};
|
|
50
|
+
const fill = solid(properties, context, report, 'cell-fill');
|
|
51
|
+
if (properties._opfUnresolvedTableFill) report('unsupported-table-cell-fill','The native fill style reference or placeholder color could not be resolved from this archive.');
|
|
52
|
+
if (fill !== undefined) style.fill = fill;
|
|
53
|
+
// DrawingML defaults are 0.1 inch horizontally and 0.05 inch vertically.
|
|
54
|
+
for (const [edge,key,fallback] of [['top','marT',45720],['right','marR',91440],['bottom','marB',45720],['left','marL',91440]]) {
|
|
55
|
+
const value = Number(properties[key] ?? fallback) / (9525 * scale);
|
|
56
|
+
if (Number.isFinite(value) && value >= 0 && value <= 200) style.padding[edge] = value;
|
|
57
|
+
else {style.padding[edge] = Math.min(200,fallback / (9525 * scale));report('unsupported-table-padding','The native cell margin is outside the canonical 0–200 reference-pixel range.');}
|
|
58
|
+
}
|
|
59
|
+
const vertical = {t:'top',ctr:'middle',b:'bottom'}[properties.anchor ?? 't'];
|
|
60
|
+
if (vertical) style.verticalAlign = vertical;
|
|
61
|
+
else report('unsupported-table-alignment','Distributed or justified vertical cell alignment is not represented.');
|
|
62
|
+
if (truth(properties.anchorCtr) || properties.vert && properties.vert !== 'horz') report('unsupported-table-alignment','Centered anchoring or vertical text direction is not represented.');
|
|
63
|
+
const listStyle = drawingObject(child(body,'a:lstStyle'));
|
|
64
|
+
const alignments = nodes(body,'a:p').map(paragraph => {
|
|
65
|
+
const p = drawingObject(paragraph['a:p'])['a:pPr'] ?? {};
|
|
66
|
+
return p.algn ?? listStyle[`a:lvl${Number(p.lvl ?? 0)+1}pPr`]?.algn ?? listStyle['a:defPPr']?.algn ?? 'l';
|
|
67
|
+
});
|
|
68
|
+
const alignment = {l:'left',ctr:'center',r:'right'}[alignments[0] ?? 'l'];
|
|
69
|
+
if (alignment) style.align = alignment;
|
|
70
|
+
if (!alignment || new Set(alignments).size > 1) report('unsupported-table-alignment','Mixed or justified paragraph alignment cannot be represented by one cell alignment.');
|
|
71
|
+
for (const [edge,key] of [['left','a:lnL'],['right','a:lnR'],['top','a:lnT'],['bottom','a:lnB']]) {
|
|
72
|
+
const line = properties[key];
|
|
73
|
+
style.borders[edge] = {color:'#00000000',width:0};
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
if (has(line,'a:noFill') || line.w === '0') continue;
|
|
76
|
+
if (line._opfUnresolvedTableLine) report('unsupported-table-border','The native line style reference or placeholder color could not be resolved from this archive.');
|
|
77
|
+
const hasFill=['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill'].some(key=>has(line,key));
|
|
78
|
+
const color = solid(line,context,report,'border') ?? (!hasFill&&!line._opfUnresolvedTableLine?'#000000':undefined);
|
|
79
|
+
const width = Number(line.w ?? 12700) / (9525 * scale);
|
|
80
|
+
const dash = {solid:'solid',dash:'dash',sysDash:'dash',sysDot:'dot',dot:'dot'}[line['a:prstDash']?.val ?? 'solid'];
|
|
81
|
+
if (color !== undefined && Number.isFinite(width) && width >= 0 && width <= 32 && dash) style.borders[edge] = {color,width,...(dash === 'solid' ? {} : {dash})};
|
|
82
|
+
else report('unsupported-table-border','The native border color, width or dash pattern cannot be represented.');
|
|
83
|
+
if (line.cmpd && line.cmpd !== 'sng' || line.algn && line.algn !== 'ctr' || has(line,'a:custDash')) report('unsupported-table-border','Compound, inset or custom-dash border geometry is not represented.');
|
|
84
|
+
}
|
|
85
|
+
if (['a:lnTlToBr','a:lnBlToTr','a:cell3D'].some(key => has(properties,key))) report('unsupported-table-cell-effect','Diagonal cell borders and 3D cell effects are not represented.');
|
|
86
|
+
return style;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Accept a native dense merge only when every covered position is empty and
|
|
90
|
+
* carries the corresponding continuation flags. On malformed input, retain all
|
|
91
|
+
* source cells without merges rather than hiding text behind a guessed anchor. */
|
|
92
|
+
export function nativeTableGrid(cells, columnCount, headers, report) {
|
|
93
|
+
const owners = cells.map(row => Array(row.length));
|
|
94
|
+
let malformed = false;
|
|
95
|
+
const bad = (r,c,message) => {malformed=true;report(`rows.${r}.${c}`,'unsupported-table-merge',message);};
|
|
96
|
+
const rows = cells.map(row => row.map(cell => ({value:cell.value,style:cell.style})));
|
|
97
|
+
for (let r=0;r<cells.length;r++) for (let c=0;c<cells[r].length;c++) {
|
|
98
|
+
const cell=cells[r][c], geometry=cell.geometry, owner=owners[r][c];
|
|
99
|
+
const horizontal=truth(geometry.hMerge),vertical=truth(geometry.vMerge);
|
|
100
|
+
if (owner) {
|
|
101
|
+
if (text(cell.value) !== '' || horizontal !== (c > owner.c) || vertical !== (r > owner.r)) bad(r,c,'Native merge continuations conflict with their anchor or contain text; all source cell text is retained.');
|
|
102
|
+
rows[r][c]=null;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const rowSpan=Number(geometry.rowSpan ?? 1),colSpan=Number(geometry.gridSpan ?? 1);
|
|
106
|
+
if (horizontal || vertical || !Number.isSafeInteger(rowSpan) || !Number.isSafeInteger(colSpan) || rowSpan<1 || colSpan<1 || r+rowSpan>cells.length || c+colSpan>columnCount) {
|
|
107
|
+
bad(r,c,'Native merge geometry is incomplete or outside the table grid; all source cell text is retained.');continue;
|
|
108
|
+
}
|
|
109
|
+
if (rowSpan>1) rows[r][c].rowSpan=rowSpan;
|
|
110
|
+
if (colSpan>1) rows[r][c].colSpan=colSpan;
|
|
111
|
+
if (rowSpan === 1 && colSpan === 1) continue;
|
|
112
|
+
for (let y=r;y<r+rowSpan;y++) for (let x=c;x<c+colSpan;x++) {
|
|
113
|
+
if (!cells[y]?.[x] || owners[y][x]) bad(r,c,'Native merge rectangles overlap or omit covered cells; all source cell text is retained.');
|
|
114
|
+
else owners[y][x]={r,c};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (malformed) return {headers,rows:cells.map(row=>row.map(cell=>({value:cell.value,style:cell.style})))};
|
|
118
|
+
// A canonical merged cell has one border per full edge. Native continuation
|
|
119
|
+
// cells can specify different edge segments; retain the anchor and report
|
|
120
|
+
// the loss rather than silently claiming that segmented border is preserved.
|
|
121
|
+
for (let r=0;r<cells.length;r++) for (let c=0;c<cells[r].length;c++) {
|
|
122
|
+
const owner=owners[r][c];
|
|
123
|
+
if (!owner || owner.r===r && owner.c===c) continue;
|
|
124
|
+
const anchor=rows[owner.r][owner.c],cell=cells[r][c];
|
|
125
|
+
const perimeter={top:r===owner.r,left:c===owner.c,bottom:r===owner.r+(anchor.rowSpan??1)-1,right:c===owner.c+(anchor.colSpan??1)-1};
|
|
126
|
+
const differs=edge=>{
|
|
127
|
+
const a=anchor.style.borders[edge],b=cell.style.borders[edge];
|
|
128
|
+
if (a.width===0 && b.width===0) return false;
|
|
129
|
+
return a.width!==b.width || a.color!==b.color || (a.dash??'solid')!==(b.dash??'solid');
|
|
130
|
+
};
|
|
131
|
+
if ((cell.borderEdges??[]).some(edge=>perimeter[edge]&&differs(edge))) report(`rows.${r}.${c}`,'unsupported-table-merge-border','Native continuation cells specify differing border segments around a merged cell. The merge and anchor borders are retained; segmented borders are not represented.');
|
|
132
|
+
}
|
|
133
|
+
if (headers && rows[0]?.some(cell=>cell?.rowSpan>1)) {
|
|
134
|
+
headers=false;
|
|
135
|
+
report('','table-header-in-body','A native merge crosses the first row. It is retained in body rows with explicit formatting, because canonical repeated headers cannot span into body rows.');
|
|
136
|
+
}
|
|
137
|
+
return {headers,rows};
|
|
138
|
+
}
|
package/dist/table-import.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {drawingObject, readSlideTheme} from './background-import.js';
|
|
2
2
|
import {readBackgroundColor} from './background.js';
|
|
3
|
+
import {nativeCellStyle, nativeTableGrid, nativeStyleFill} from './table-cell-import.js';
|
|
4
|
+
import {nativeCellExtent,inheritNativeBorders,nativeBorderProperties,mergeCellBorderOverrides} from './table-border-import.js';
|
|
3
5
|
|
|
4
6
|
const nodes = (tree, tag) => (tree ?? []).filter(node => Object.hasOwn(node, tag));
|
|
5
7
|
const child = (tree, tag) => nodes(tree, tag)[0]?.[tag];
|
|
6
8
|
const attrs = (tree, tag) => nodes(tree, tag)[0]?.[':@'] ?? {};
|
|
7
9
|
const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
|
|
8
|
-
const boolean = value =>
|
|
10
|
+
const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false','off'].includes(value) ? false : undefined;
|
|
9
11
|
|
|
10
12
|
function runStyle(properties, context, relationships, report) {
|
|
11
13
|
const result = {};
|
|
@@ -24,6 +26,7 @@ function runStyle(properties, context, relationships, report) {
|
|
|
24
26
|
result.subscript = baseline < 0;
|
|
25
27
|
}
|
|
26
28
|
const font = properties['a:latin']?.typeface;
|
|
29
|
+
if (properties['a:latin'] && !font) report('unsupported-table-font', 'The native text font has no usable Latin face or theme reference.');
|
|
27
30
|
if (font) {
|
|
28
31
|
const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
|
|
29
32
|
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;
|
|
@@ -51,7 +54,7 @@ function runStyle(properties, context, relationships, report) {
|
|
|
51
54
|
// overrides a bold paragraph default, and a new fill replaces the inherited fill.
|
|
52
55
|
function mergeProperties(...levels) {
|
|
53
56
|
const result = {};
|
|
54
|
-
const fills = ['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill'];
|
|
57
|
+
const fills = ['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill','_opfUnresolvedTableFill'];
|
|
55
58
|
for (const level of levels) {
|
|
56
59
|
if (!level) continue;
|
|
57
60
|
if (fills.some(key => Object.hasOwn(level, key))) for (const key of fills) delete result[key];
|
|
@@ -60,7 +63,7 @@ function mergeProperties(...levels) {
|
|
|
60
63
|
return result;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
function cellText(body, context, relationships, report, header) {
|
|
66
|
+
function cellText(body, context, relationships, report, header, tableDefaults) {
|
|
64
67
|
const listStyle = drawingObject(child(body, 'a:lstStyle'));
|
|
65
68
|
const paragraphs = nodes(body, 'a:p');
|
|
66
69
|
const runs = [];
|
|
@@ -73,7 +76,7 @@ function cellText(body, context, relationships, report, header) {
|
|
|
73
76
|
if (index) append('\n');
|
|
74
77
|
const pPr = drawingObject(content)['a:pPr'] ?? {};
|
|
75
78
|
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']);
|
|
79
|
+
const defaults = mergeProperties(header ? {b:'0'} : undefined, tableDefaults, listStyle['a:defPPr']?.['a:defRPr'], listStyle[`a:lvl${level}pPr`]?.['a:defRPr'], pPr['a:defRPr']);
|
|
77
80
|
for (const node of content) {
|
|
78
81
|
const tag = ['a:r', 'a:fld', 'a:br'].find(tag => Object.hasOwn(node, tag));
|
|
79
82
|
if (!tag) continue;
|
|
@@ -84,25 +87,126 @@ function cellText(body, context, relationships, report, header) {
|
|
|
84
87
|
return runs.some(run => typeof run !== 'string') ? runs : runs.join('');
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
// Table styles are archive data, resolved through the presentation relationship.
|
|
91
|
+
// The list's def attribute is an insertion default, not an implicit style for
|
|
92
|
+
// existing unstyled tables. Built-in IDs without archive definitions stay explicit
|
|
93
|
+
// diagnostics; do not substitute a guessed theme or download style definitions.
|
|
94
|
+
function tableStyle(props, archive, context, report) {
|
|
95
|
+
const inline = child(props, 'a:tableStyle');
|
|
96
|
+
if (inline) return drawingObject(inline);
|
|
97
|
+
const id = text(child(props, 'a:tableStyleId')).trim().toLowerCase();
|
|
98
|
+
if (!id) return undefined;
|
|
99
|
+
const relationship = [...archive.relationships('ppt/presentation.xml').values()]
|
|
100
|
+
.find(rel => rel.type.endsWith('/tableStyles') && rel.targetMode !== 'External' && archive.bytes(rel.path));
|
|
101
|
+
const list = relationship && child(context.parsedPart(relationship.path)?.tree, 'a:tblStyleLst');
|
|
102
|
+
const selected = nodes(list, 'a:tblStyle').find(node => node[':@']?.styleId?.trim().toLowerCase() === id);
|
|
103
|
+
if (selected) return drawingObject(selected['a:tblStyle']);
|
|
104
|
+
report('unsupported-table-style', 'The referenced native table style has no definition in this archive; direct text formatting is retained.');
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const colorKinds = ['a:srgbClr','a:sysClr','a:schemeClr','a:scrgbClr','a:hslClr','a:prstClr'];
|
|
109
|
+
function tableTextProperties(style, context) {
|
|
110
|
+
const result = {};
|
|
111
|
+
for (const key of ['b','i']) {
|
|
112
|
+
const value = boolean(style[key]);
|
|
113
|
+
if (value !== undefined) result[key] = value ? '1' : '0';
|
|
114
|
+
}
|
|
115
|
+
if (style['a:font']) {
|
|
116
|
+
result['a:latin'] = style['a:font']['a:latin'] ?? {};
|
|
117
|
+
} else if (style['a:fontRef']) {
|
|
118
|
+
const index = style['a:fontRef'].idx;
|
|
119
|
+
result['a:latin'] = {typeface:index === 'major' ? '+mj-lt' : index === 'minor' ? '+mn-lt' : ''};
|
|
120
|
+
}
|
|
121
|
+
const color = Object.fromEntries(colorKinds.filter(key => Object.hasOwn(style, key)).map(key => [key, style[key]]));
|
|
122
|
+
if (Object.keys(color).length) {
|
|
123
|
+
const placeholder = readBackgroundColor(style['a:fontRef'], context);
|
|
124
|
+
const resolved = readBackgroundColor(color, {...context, placeholder});
|
|
125
|
+
// Normalize style placeholder colors before merging direct paragraph/run fills.
|
|
126
|
+
result['a:solidFill'] = resolved ? {'a:srgbClr':{val:resolved.hex.slice(1), 'a:alpha':{val:String(Math.round(resolved.alpha * 100000))}}} : color;
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function conditionalProperties(style, properties, row, column, rowCount, columnCount, context, report, geometry) {
|
|
132
|
+
if (!style) return {text:undefined,cell:{}};
|
|
133
|
+
const enabled = key => boolean(properties[key]) === true;
|
|
134
|
+
const firstRow = enabled('firstRow') && row === 0;
|
|
135
|
+
const lastRow = enabled('lastRow') && row === rowCount - 1;
|
|
136
|
+
const firstCol = enabled('firstCol') && column === 0;
|
|
137
|
+
const lastCol = enabled('lastCol') && column === columnCount - 1;
|
|
138
|
+
// DrawingML / MS-OI29500 2.1.1265 order (not WordprocessingML order).
|
|
139
|
+
const parts = ['wholeTbl'];
|
|
140
|
+
if (enabled('bandRow') && !firstRow && !lastRow) parts.push((row + Number(enabled('firstRow'))) % 2 ? 'band2H' : 'band1H');
|
|
141
|
+
if (enabled('bandCol') && !firstCol && !lastCol) parts.push((column + Number(enabled('firstCol'))) % 2 ? 'band2V' : 'band1V');
|
|
142
|
+
if (lastCol) parts.push('lastCol');
|
|
143
|
+
if (firstCol) parts.push('firstCol');
|
|
144
|
+
if (lastRow) parts.push('lastRow');
|
|
145
|
+
if (lastRow && lastCol) parts.push('seCell');
|
|
146
|
+
if (lastRow && firstCol) parts.push('swCell');
|
|
147
|
+
if (firstRow) parts.push('firstRow');
|
|
148
|
+
if (firstRow && lastCol) parts.push('neCell');
|
|
149
|
+
if (firstRow && firstCol) parts.push('nwCell');
|
|
150
|
+
const textStyle = {};
|
|
151
|
+
let cellStyle = {};
|
|
152
|
+
let borders = {};
|
|
153
|
+
const extent=nativeCellExtent(row,column,rowCount,columnCount,geometry);
|
|
154
|
+
for (const name of parts) {
|
|
155
|
+
const part = style['a:' + name];
|
|
156
|
+
if (!part) continue;
|
|
157
|
+
const decoration=part['a:tcStyle'] ?? {};
|
|
158
|
+
cellStyle=mergeProperties(cellStyle,nativeStyleFill(decoration,context));
|
|
159
|
+
borders=inheritNativeBorders(borders,decoration,name,extent,context);
|
|
160
|
+
if (Object.keys(decoration).some(key=>!['a:fill','a:fillRef','a:tcBdr'].includes(key))) report('unsupported-table-cell-style','The conditional cell style includes effects that are not represented; supported fills, borders and character styles are retained.');
|
|
161
|
+
const current = part['a:tcTxStyle'] ?? {};
|
|
162
|
+
// Resolve choices after inheritance so a later phClr can use an inherited
|
|
163
|
+
// fontRef, and an explicit font collection replaces a themed reference.
|
|
164
|
+
if (current['a:font'] || current['a:fontRef']) {delete textStyle['a:font'];delete textStyle['a:fontRef'];}
|
|
165
|
+
if (colorKinds.some(key => Object.hasOwn(current, key))) for (const key of colorKinds) delete textStyle[key];
|
|
166
|
+
for (const [key, value] of Object.entries(current)) {
|
|
167
|
+
if (['b','i'].includes(key) && value === 'def') continue;
|
|
168
|
+
textStyle[key] = value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {text:tableTextProperties(textStyle, context),cell:{...cellStyle,...nativeBorderProperties(borders,extent)}};
|
|
172
|
+
}
|
|
173
|
+
|
|
87
174
|
// Use the same direct graphic-frame ordering as the slide collector. The ordered
|
|
88
175
|
// reader retains interleaved runs, fields, breaks, empty paragraphs and spaces.
|
|
89
|
-
export function importTableFrames(slidePath, archive, relationships, report) {
|
|
176
|
+
export function importTableFrames(slidePath, archive, relationships, report, dimensions) {
|
|
90
177
|
const context = readSlideTheme(slidePath, archive);
|
|
178
|
+
const scale = Math.min(dimensions.widthInches,dimensions.heightInches) * 96 / 720;
|
|
91
179
|
const root = child(context.parsedPart(slidePath)?.tree, 'p:sld');
|
|
92
180
|
const tree = child(child(root, 'p:cSld'), 'p:spTree');
|
|
93
181
|
return nodes(tree, 'p:graphicFrame').map((frame, frameIndex) => {
|
|
94
182
|
const table = child(child(child(frame['p:graphicFrame'], 'a:graphic'), 'a:graphicData'), 'a:tbl');
|
|
95
183
|
if (!table) return undefined;
|
|
96
184
|
const props = child(table, 'a:tblPr');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
185
|
+
const style = tableStyle(props, archive, context, (code, message) => report(frameIndex, '', code, message));
|
|
186
|
+
if (style?.['a:tblBg']) report(frameIndex, '', 'unsupported-table-cell-style', 'The native table style background is not represented; supported character styles are retained.');
|
|
187
|
+
const properties = attrs(table, 'a:tblPr');
|
|
188
|
+
if (boolean(properties.rtl) === true) report(frameIndex, '', 'unsupported-table-direction', 'Right-to-left table geometry is not represented; cells retain native source order.');
|
|
189
|
+
const headers = boolean(properties.firstRow) === true;
|
|
190
|
+
const nativeRows = nodes(table, 'a:tr');
|
|
191
|
+
const columnCount = nodes(child(table, 'a:tblGrid'), 'a:gridCol').length;
|
|
192
|
+
const rows = nativeRows.map((row, rowIndex) => nodes(row['a:tr'], 'a:tc').map((cell, columnIndex) => {
|
|
100
193
|
const cellPath = `rows.${rowIndex}.${columnIndex}`;
|
|
101
|
-
const
|
|
194
|
+
const reported = new Set();
|
|
195
|
+
const emit = (code, message) => {if (!reported.has(code)) {reported.add(code);report(frameIndex, cellPath, code, message);}};
|
|
102
196
|
const geometry = cell[':@'] ?? {};
|
|
103
|
-
|
|
104
|
-
|
|
197
|
+
const defaults = conditionalProperties(style, properties, rowIndex, columnIndex, nativeRows.length, columnCount, context, emit, geometry);
|
|
198
|
+
const body = child(cell['a:tc'],'a:txBody');
|
|
199
|
+
const cellProperties = {...attrs(cell['a:tc'],'a:tcPr'),...drawingObject(child(cell['a:tc'],'a:tcPr'))};
|
|
200
|
+
const inherited=mergeProperties(defaults.cell,cellProperties);
|
|
201
|
+
return {
|
|
202
|
+
geometry,
|
|
203
|
+
value:cellText(body, context, relationships, emit, headers && rowIndex === 0, defaults.text),
|
|
204
|
+
style:nativeCellStyle(mergeCellBorderOverrides(defaults.cell,cellProperties,inherited),body,context,scale,emit),
|
|
205
|
+
borderEdges:[['left','a:lnL'],['right','a:lnR'],['top','a:lnT'],['bottom','a:lnB']]
|
|
206
|
+
.filter(([,key])=>Object.hasOwn(inherited,key)).map(([edge])=>edge),
|
|
207
|
+
};
|
|
105
208
|
}));
|
|
106
|
-
|
|
209
|
+
const grid = nativeTableGrid(rows,columnCount,headers,(cell,code,message)=>report(frameIndex,cell,code,message));
|
|
210
|
+
return grid.headers && grid.rows.length ? {columns:grid.rows[0], rows:grid.rows.slice(1)} : {rows:grid.rows};
|
|
107
211
|
});
|
|
108
212
|
}
|