@openpresentation/opf-pptx 0.8.0 → 0.9.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.
@@ -1,5 +1,9 @@
1
1
  # PptxGenJS runtime provenance
2
2
 
3
+ ## Coordinated `@openpresentation/opf` pin
4
+
5
+ PPTX 0.9.0 depends on published [`@openpresentation/opf@^0.11.0`](https://www.npmjs.com/package/@openpresentation/opf) (reference layer: ColorRef, document `variables`, exported `resolveColorRef()`). CI checks out OPF [`b8a1faf`](https://github.com/OpenPresentation/opf/commit/b8a1faf24203635bd36b09ea19f44bcd53781f16) (`opf-v0.11.0`) for coordinated source linking. Named content colors resolve through core `resolveColorRef()`; eight-digit hex alpha stays a local export concern because core `normalizeHexColor` drops the alpha byte. Native `schemeClr` / theme `clrScheme` writes remain follow-up work.
6
+
3
7
  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
8
 
5
9
  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.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # OPF PPTX
2
2
 
3
+ Version 0.9.0 requires core 0.11.0, including ColorRef / `variables` on content colors. It hex-resolves named colors through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
4
+
5
+ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
6
+
3
7
  Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
4
8
 
5
9
  Version 0.8.0 consumes shared heading/scalar/rich line placement, including `textRasterPadding`, through editable native line boxes without autofit. Complete heading tags retain title/subtitle/tag roles, line ordering and current edited text during import. Source boundary metadata also records hard-line separators, so complete scalar/heading groups recover current native text with spaces, tabs and exact authored line endings. Incomplete, duplicate, ambiguous or bulleted groups fall back to ordinary import with diagnostics. Legacy heading tags retain their prior native-line behavior; arbitrary formatting, nesting and geometry are not reconstructed. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
@@ -68,7 +72,7 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
68
72
 
69
73
  - `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
70
74
  - Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
71
- - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects.
75
+ - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) flatten to `a:srgbClr` via core `resolveColorRef()`; this is hex-resolve, not native `schemeClr` / theme write.
72
76
  - Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path.
73
77
  - ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes.
74
78
 
@@ -232,3 +236,7 @@ Export consumes the same core geometry as SVG. It writes native merged cells and
232
236
  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.
233
237
 
234
238
  Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
239
+
240
+ The unpublished furniture candidate exports core's accepted header/footer text and picture geometry. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
241
+
242
+ Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; this behavior is not yet published.
@@ -24,9 +24,9 @@ export function attachCodeTags(entries, records) {
24
24
  return attachTextTags(entries,records,TAG,'opfCode','code');
25
25
  }
26
26
 
27
- export {unhex as decodeTextTag};
27
+ export {unhex as decodeTextTag, hex as encodeTextTag};
28
28
 
29
- export function attachTextTags(entries, records, tagName, prefix, kind) {
29
+ export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false} = {}) {
30
30
  if (!records.size) return;
31
31
  let count = 0;
32
32
  const seen = new Set(), types = [];
@@ -34,7 +34,8 @@ export function attachTextTags(entries, records, tagName, prefix, kind) {
34
34
  const relPath = path.replace('/slides/','/slides/_rels/') + '.rels';
35
35
  let rels = dec.decode(entries[relPath]);
36
36
  const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match=>match[1]));
37
- const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
37
+ const pattern = pictures ? /<p:(sp|pic)>[\s\S]*?<\/p:\1>/g : /<p:sp>[\s\S]*?<\/p:sp>/g;
38
+ const xml = dec.decode(entries[path]).replace(pattern, shape=>{
38
39
  const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
39
40
  if (!records.has(name)) return shape;
40
41
  if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
@@ -0,0 +1,97 @@
1
+ // Export ColorRef through published core resolveColorRef(). Keep 8-digit hex
2
+ // alpha locally: normalizeHexColor() strips the AA byte, which PptxGenJS needs.
3
+
4
+ import { resolveColorRef as resolveCoreColorRef } from "@openpresentation/opf";
5
+
6
+ const UNRESOLVED = "";
7
+
8
+ function isPlainObject(value) {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ function withHash(value) {
13
+ if (typeof value !== "string") return undefined;
14
+ const trimmed = value.trim();
15
+ if (!trimmed) return undefined;
16
+ return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
17
+ }
18
+
19
+ function expandShorthand(hex) {
20
+ const raw = hex.replace(/^#/, "");
21
+ if (/^[0-9a-f]{3}$/i.test(raw)) return raw.split("").map((char) => char + char).join("");
22
+ return raw;
23
+ }
24
+
25
+ /** Literal #RGB / #RRGGBB / #RRGGBBAA, including pptx-internal hex without '#'. */
26
+ function literalExportHex(entry) {
27
+ if (typeof entry !== "string") return undefined;
28
+ const hashed = withHash(entry.trim());
29
+ if (!hashed) return undefined;
30
+ if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hashed)) {
31
+ return `#${expandShorthand(hashed).toUpperCase()}`;
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /** Pass document variables through; core accepts hex shorthand or `{ type: "color", value }`. */
37
+ export function resolveVariableColors(variables) {
38
+ return isPlainObject(variables) ? variables : {};
39
+ }
40
+
41
+ function coreRoles(ctx) {
42
+ const colors = ctx.colors ?? {};
43
+ return {
44
+ background: withHash(colors.background),
45
+ surface: withHash(colors.surface),
46
+ text: withHash(colors.text),
47
+ textSecondary: withHash(colors.mutedText),
48
+ accent: withHash(colors.accent),
49
+ };
50
+ }
51
+
52
+ function coreOptions(ctx, fallback = UNRESOLVED) {
53
+ return {
54
+ colorScheme: ctx.colorScheme ?? {},
55
+ roles: coreRoles(ctx),
56
+ variables: ctx.variables ?? {},
57
+ fallback,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Resolve a ColorRef or TextRun.color string to a #RRGGBB or #RRGGBBAA value.
63
+ * Unrecognized values return undefined so callers can apply the theme fallback.
64
+ */
65
+ export function resolveColorRefValue(entry, ctx) {
66
+ if (entry === undefined || entry === null || entry === "") return undefined;
67
+ if (typeof entry !== "string") return undefined;
68
+ const trimmed = entry.trim();
69
+ const literal = literalExportHex(trimmed);
70
+ if (literal) return literal;
71
+ const resolved = resolveCoreColorRef(trimmed, coreOptions(ctx, UNRESOLVED));
72
+ return resolved || undefined;
73
+ }
74
+
75
+ export function colorContext(context, fallback) {
76
+ return {
77
+ colorScheme: context.colorScheme,
78
+ colors: context.colors,
79
+ variables: context.variables ?? {},
80
+ fallback: fallback ?? context.colors?.text ?? "000000",
81
+ };
82
+ }
83
+
84
+ /** PptxGenJS export color: six or eight uppercase hex digits without '#'. */
85
+ export function resolveExportColor(entry, ctx) {
86
+ const fallback = ctx.fallback ?? ctx.colors?.text ?? "000000";
87
+ const resolved = resolveColorRefValue(entry, ctx);
88
+ const hex = resolved ?? withHash(fallback) ?? "#000000";
89
+ const raw = expandShorthand(hex.replace(/^#/, ""));
90
+ if (/^[0-9A-F]{6}([0-9A-F]{2})?$/i.test(raw)) return raw.toUpperCase();
91
+ return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
92
+ }
93
+
94
+ export function exportColorAlpha(entry, ctx) {
95
+ const raw = resolveExportColor(entry, ctx);
96
+ return raw.length === 8 ? (1 - parseInt(raw.slice(6), 16) / 255) * 100 : 0;
97
+ }
@@ -0,0 +1,214 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
3
+ import {sourceLineParagraphs} from './text-provenance.js';
4
+
5
+ const TAG = 'OPF_FURNITURE_V1';
6
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
7
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
8
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
9
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
10
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
11
+ const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
12
+ const fields = ['text', 'image', 'organization', 'section', 'slideNumber', 'date'];
13
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ const key = part => `${part.kind}.${part.zone}.${part.field}`;
15
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
16
+ const check = (condition, message) => { if (!condition) throw Error(message); };
17
+
18
+ // This manifest contains topology, identities and inactive flags only. Current
19
+ // native shapes supply all words, image bytes and alt text, even after edits.
20
+ export function furnitureManifest(presentation, slide, layout, slideIndex) {
21
+ const definitions = {};
22
+ for (const kind of kinds) {
23
+ const local = slide.design?.[kind] !== undefined;
24
+ const source = local ? slide.design[kind] : presentation.design?.[kind];
25
+ if (source === undefined) continue;
26
+ const value = source === false ? false : {};
27
+ if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
28
+ value[zone] = {};
29
+ for (const field of fields) if (source[zone][field] !== undefined) {
30
+ const current = source[zone][field];
31
+ value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
32
+ }
33
+ }
34
+ definitions[kind] = {scope: local ? 'local' : 'global', value};
35
+ }
36
+ if (!Object.keys(definitions).length) return null;
37
+ const organizations = array(presentation.organization);
38
+ const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
39
+ return {v: 1, role: 'slide', group: String(slideIndex), definitions,
40
+ ...(layout.parts.some(part => part.field === 'organization') ? {organizationId: organization?.id} : {}),
41
+ parts: layout.parts.map(part => ({kind: part.kind, zone: part.zone, field: part.field,
42
+ type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length}))};
43
+ }
44
+
45
+ export function attachFurnitureTags(entries, records, manifests) {
46
+ attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
47
+ const types = [];
48
+ for (const [path, manifest] of manifests) {
49
+ const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
50
+ const relPath = path.replace('/slides/', '/slides/_rels/') + '.rels';
51
+ let xml = dec.decode(entries[path]), rels = dec.decode(entries[relPath]);
52
+ // Common-slide customer data follows spTree, before controls/extLst.
53
+ // No invisible/fake shape is necessary for false or empty definitions.
54
+ const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
55
+ let id = 'rIdOpfFurnitureSlide';
56
+ while (ids.has(id)) id += '_';
57
+ check(!entries[part] && xml.includes('</p:spTree>'), 'Missing slide tree or colliding furniture tag part.');
58
+ xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>`);
59
+ rels = rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfFurnitureSlide${manifest.group}.xml"/></Relationships>`);
60
+ entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag(manifest)}"/></p:tagLst>`);
61
+ entries[path] = enc.encode(xml);
62
+ entries[relPath] = enc.encode(rels);
63
+ types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
64
+ }
65
+ if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
66
+ }
67
+
68
+ function readTags(container, relationships, entries) {
69
+ const tags = [];
70
+ let unreadable = false;
71
+ for (const link of array(container?.['p:tags'])) {
72
+ const rel = relationships.get(link['r:id']);
73
+ try {
74
+ check(rel?.type === REL && rel.targetMode !== 'External' && entries[rel.path], 'Missing tag relationship.');
75
+ tags.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));
76
+ } catch { unreadable = true; }
77
+ }
78
+ return {tags: tags.filter(tag => tag.name?.toUpperCase() === TAG),
79
+ ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name)).length !== 1};
80
+ }
81
+
82
+ function validateManifest(manifest) {
83
+ check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
84
+ check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
85
+ check(Array.isArray(manifest.parts) && manifest.parts.length <= 36, 'Invalid furniture parts.');
86
+ const expected = new Map();
87
+ for (const [kind, definition] of Object.entries(manifest.definitions)) {
88
+ check(object(definition) && ['global', 'local'].includes(definition.scope), 'Invalid furniture scope.');
89
+ const value = definition.value;
90
+ check(value === false || object(value), 'Invalid furniture definition.');
91
+ if (value === false) continue;
92
+ check(Object.keys(value).every(zone => zones.includes(zone)), 'Invalid furniture zone.');
93
+ for (const [zone, content] of Object.entries(value)) {
94
+ check(object(content) && Object.keys(content).every(field => fields.includes(field)), 'Invalid furniture fields.');
95
+ for (const [field, flag] of Object.entries(content)) {
96
+ check(field === 'image' ? flag === 'native' : field === 'text' ? flag === 'literal' :
97
+ field === 'date' ? flag === false || flag === 'literal' : typeof flag === 'boolean', 'Invalid furniture field intent.');
98
+ if (flag !== false) expected.set(`${kind}.${zone}.${field}`, field === 'image' ? 'image' : 'text');
99
+ }
100
+ }
101
+ }
102
+ check(manifest.parts.length === expected.size, 'Incomplete furniture topology.');
103
+ for (const part of manifest.parts) {
104
+ check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
105
+ check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
106
+ expected.delete(key(part));
107
+ }
108
+ if (manifest.parts.some(part => part.field === 'organization')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
109
+ }
110
+
111
+ function readSlide(context, entries, slideIndex, report, taggedText) {
112
+ const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
113
+ const candidates = {};
114
+ const records = [];
115
+ let invalidRecord = false;
116
+ for (const [type, nodes, properties] of [['text', shapes, 'p:nvSpPr'], ['image', pictures, 'p:nvPicPr']]) {
117
+ for (const [index, node] of nodes.entries()) {
118
+ const result = readTags(node[properties]?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
119
+ if (type === 'text' && result.tags.length) taggedText.add(index);
120
+ for (const tag of result.tags) try {
121
+ check(!result.ambiguous, 'Multiple identities on a furniture shape.');
122
+ records.push({type, index, data: decodeTextTag(tag.val)});
123
+ } catch { invalidRecord = true; }
124
+ }
125
+ }
126
+ const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
127
+ if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
128
+ let manifest;
129
+ try {
130
+ check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
131
+ manifest = decodeTextTag(tags.tags[0].val);
132
+ validateManifest(manifest);
133
+ for (const record of records) {
134
+ const data = record.data, part = manifest.parts[data?.part];
135
+ check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
136
+ }
137
+ } catch (error) { report(slideIndex, error.message); return candidates; }
138
+ for (const kind of kinds) {
139
+ const definition = manifest.definitions[kind];
140
+ if (!definition) continue;
141
+ try {
142
+ const value = definition.value === false ? false : {};
143
+ if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
144
+ value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
145
+ }
146
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], sections: []};
147
+ for (const [partIndex, part] of manifest.parts.entries()) {
148
+ if (part.kind !== kind) continue;
149
+ const group = records.filter(record => record.data.part === partIndex);
150
+ check(group.length === part.count, 'Incomplete or duplicated furniture part.');
151
+ if (part.type === 'image') {
152
+ const current = readPicture(pictures[group[0].index]);
153
+ check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
154
+ value[part.zone].image = current.payload.image;
155
+ candidate.pictures.push(group[0].index);
156
+ } else {
157
+ const ordered = Array(part.count);
158
+ for (const record of group) {
159
+ const {line, count} = record.data;
160
+ check(count === part.count && Number.isSafeInteger(line) && line >= 0 && line < part.count && !ordered[line], 'Ambiguous furniture line.');
161
+ ordered[line] = record;
162
+ }
163
+ const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
164
+ if (part.field === 'slideNumber') check(text === String(slideIndex + 1), 'Current slide number differs from its position; retain the visible number as ordinary text.');
165
+ if (part.field === 'section') candidate.sections.push(text);
166
+ if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
167
+ value[part.zone][part.field] = ['text', 'date'].includes(part.field) ? text : true;
168
+ candidate.text.push(...ordered.map(record => record.index));
169
+ }
170
+ }
171
+ check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
172
+ candidates[kind] = candidate;
173
+ } catch (error) { report(slideIndex, `${kind}: ${error.message}`); }
174
+ }
175
+ return candidates;
176
+ }
177
+
178
+ // Reconcile metadata before consuming any shapes. Global inheritance is promoted
179
+ // only when every slide has a valid definition/override and all inherited values
180
+ // agree. A missing tag must never cause another slide's furniture to reappear.
181
+ export function importFurniture(contexts, entries, onDiagnostic) {
182
+ const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
183
+ const taggedText = contexts.map(() => new Set());
184
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, report, taggedText[index]));
185
+ const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
186
+ if (organizations.some(item => !same(item, organizations[0]))) {
187
+ for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
188
+ delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
189
+ }
190
+ }
191
+ for (const [index, slide] of candidates.entries()) {
192
+ const sections = Object.values(slide).flatMap(candidate => candidate.sections);
193
+ if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
194
+ delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
195
+ }
196
+ }
197
+ const result = {design: {}, slides: candidates.map((slide, index) => {
198
+ const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
199
+ return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
200
+ ...(sections.length ? {section: sections[0]} : {}),
201
+ text: new Set(values.flatMap(candidate => candidate.text)), pictures: new Set(values.flatMap(candidate => candidate.pictures)), taggedText: taggedText[index]};
202
+ })};
203
+ const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
204
+ if (validOrganizations.length) result.organization = validOrganizations[0];
205
+ for (const kind of kinds) {
206
+ const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
207
+ if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
208
+ result.design[kind] = inherited[0].value;
209
+ candidates.forEach((slide, index) => { if (slide[kind].scope === 'global') delete result.slides[index].design[kind]; });
210
+ }
211
+ }
212
+ candidates.forEach((slide, index) => { if (Object.keys(slide).length) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
213
+ return result;
214
+ }
package/dist/index.js CHANGED
@@ -6,12 +6,14 @@ import {attachCardTags,importCardFrames} from './card-provenance.js';
6
6
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
7
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
8
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
9
+ import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
9
10
  import {importImageOrientation} from './image-import.js';
10
11
  import {nativeBackgroundFill} from './background.js';
11
12
  import {importBackground} from './background-import.js';
12
13
  import { webpToPng } from '#image-fallback';
13
14
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
14
15
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
16
+ import { colorContext, resolveExportColor, resolveVariableColors } from "./color-ref.js";
15
17
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
16
18
  import { unzipSync, zipSync } from "fflate";
17
19
  import { XMLParser } from "fast-xml-parser";
@@ -181,6 +183,8 @@ export async function toPptx(input, options = {}) {
181
183
  context.headingTags = new Map();
182
184
  context.plainTextTags = new Map();
183
185
  context.timelineTags = new Map();
186
+ context.furnitureTags = new Map();
187
+ context.furnitureManifests = new Map();
184
188
  context.codeTags = new Map();
185
189
  context.metricTags = new Map();
186
190
  context.chartHeadings = new Map();
@@ -233,8 +237,21 @@ export async function fromPptx(input, options = {}) {
233
237
  if (core.author) imported.author = core.author;
234
238
  if (dimensions) imported.design = { dimensions };
235
239
 
240
+ const furnitureContexts = slidePaths.map((slidePath, index) => {
241
+ const root = parseRequiredXml(entries, slidePath)['p:sld'];
242
+ if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
243
+ const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
244
+ return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
245
+ paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
246
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships,
247
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
248
+ });
249
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
250
+ if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
251
+ if (furniture.organization) imported.organization = furniture.organization;
252
+
236
253
  for (let index = 0; index < slidePaths.length; index += 1) {
237
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
254
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
238
255
  }
239
256
 
240
257
  const result = validatePresentation(imported);
@@ -375,16 +392,9 @@ function dimensionsFromPresentation(presentationRoot) {
375
392
  };
376
393
  }
377
394
 
378
- function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
379
- const doc = parseRequiredXml(entries, slidePath);
380
- const slideRoot = doc["p:sld"];
381
- if (!slideRoot) {
382
- throw new OPFPptxError("invalid-pptx", `PPTX slide is not a PresentationML slide: ${slidePath}.`, {
383
- path: slidePath
384
- });
385
- }
386
-
387
- const relationships = parseRelationships(entries, slidePath);
395
+ function importSlide(entries, slidePath, slideIndex, presentationDimensions, options, furniture, nativeContext) {
396
+ const slideRoot = nativeContext.root;
397
+ const relationships = nativeContext.relationships;
388
398
  const dimensions = presentationDimensions ?? DIMENSION_PRESETS.widescreen;
389
399
  const slide = {};
390
400
  if (slideRoot.show === "0") slide.hidden = true;
@@ -393,8 +403,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
393
403
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
394
404
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
395
405
  if (background) slide.design = {background};
406
+ if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
407
+ if (furniture.section !== undefined) slide.section = furniture.section;
396
408
 
397
- const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
409
+ const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
398
410
  .sort(comparePositionedItems);
399
411
  // Complete OPF heading roles are authoritative. Nearby body lines must not
400
412
  // fill an absent role by geometry; explicit native placeholders still apply.
@@ -418,12 +430,14 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
418
430
  return slide;
419
431
  }
420
432
 
421
- function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
433
+ const nativePictures = tree => [...asArray(tree?.['p:pic']), ...asArray(tree?.['p:grpSp']).flatMap(nativePictures)];
434
+
435
+ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext) {
422
436
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
423
437
  const items = [];
424
- const shapes = nativeTextShapes(tree);
425
- if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text is retained, but group transforms and non-text group members are not reconstructed; review the reflowed OPF.'});
426
- const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
438
+ const shapes = nativeContext.shapes;
439
+ if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
440
+ const paragraphs = nativeContext.paragraphs;
427
441
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
428
442
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
429
443
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
@@ -446,8 +460,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
446
460
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
447
461
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
448
462
  for (const [index,shape] of shapes.entries()) {
463
+ if (furniture.text.has(index)) continue;
449
464
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
450
- const item = importShape(shape, dimensions, paragraphs[index]);
465
+ const item = importShape(shape, dimensions, paragraphs[index], furniture.taggedText.has(index));
466
+ // A damaged/edited furniture group falls back to current native text,
467
+ // including cleared text boxes, without inventing a title or shape label.
468
+ if (item && furniture.taggedText.has(index)) item.sourceText = true;
451
469
  if (item) items.push(item);
452
470
  }
453
471
 
@@ -461,7 +479,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
461
479
  if (item) items.push(item);
462
480
  }
463
481
 
464
- for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
482
+ for (const [index, picture] of nativeContext.pictures.entries()) {
483
+ if (furniture.pictures.has(index)) continue;
465
484
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
466
485
  const item = importPicture(entries, picture, slidePath, relationships, report);
467
486
  if (item) items.push(item);
@@ -538,7 +557,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
538
557
  function importPicture(entries, picture, slidePath, relationships, report) {
539
558
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
540
559
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
541
- const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
560
+ const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
542
561
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
543
562
  const relationship = relationships.get(relId);
544
563
  let bytes = relationship?.path ? entries[relationship.path] : null;
@@ -890,10 +909,15 @@ function resolvePresentationContext(presentation, options) {
890
909
  accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
891
910
  surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
892
911
  border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
893
- }
912
+ },
913
+ variables: resolveVariableColors(presentation.variables)
894
914
  };
895
915
  }
896
916
 
917
+ function exportColor(entry, context, fallback) {
918
+ return resolveExportColor(entry, colorContext(context, fallback));
919
+ }
920
+
897
921
  function configurePresentation(pptx, presentation, context) {
898
922
  pptx.defineLayout({
899
923
  name: context.layoutName,
@@ -926,8 +950,9 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
926
950
  const { widthInches, heightInches } = slideContext.dimensions;
927
951
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
928
952
  if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
929
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
953
+ const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
930
954
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
955
+ await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
931
956
  for (const item of geometry.items) {
932
957
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
933
958
  if (item.frameBox) {
@@ -958,7 +983,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
958
983
  } else if (item.field === "text" && item.text?.richLines) {
959
984
  const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
960
985
  for(const [index,line] of item.text.richLines.entries()){
961
- const runs=line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(fragment.run.color??slideContext.colors.text),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??slideContext.colors.text)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
986
+ const runs=line.fragments.map(fragment=>{
987
+ const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
988
+ return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(runColor),underline:fragment.run.underline?{color:normalizeHex(runColor)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
989
+ });
962
990
  const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
963
991
  const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
964
992
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
@@ -980,7 +1008,7 @@ function resolveSlideContext(presentation, slide, baseContext, options) {
980
1008
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
981
1009
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
982
1010
  }
983
- return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, imageFill: effective.design.imageFill ?? "fit" };
1011
+ return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
984
1012
  }
985
1013
 
986
1014
  function fieldToType(field) {
@@ -1047,8 +1075,12 @@ function addTextPayload(slide, value, region, context) {
1047
1075
  slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
1048
1076
  }
1049
1077
 
1050
- function richLineRuns(line,color) {
1051
- return line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(fragment.run.color??color),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??color)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
1078
+ function richLineRuns(line,color,context) {
1079
+ const fallback=color.replace(/^#/,'');
1080
+ return line.fragments.map(fragment=>{
1081
+ const runColor=exportColor(fragment.run.color,context,fallback);
1082
+ return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(runColor),underline:fragment.run.underline?{color:normalizeHex(runColor)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1083
+ });
1052
1084
  }
1053
1085
  function addMeasuredList(slide,fit,context) {
1054
1086
  for(const entry of fit.listEntries){
@@ -1059,7 +1091,7 @@ function addMeasuredList(slide,fit,context) {
1059
1091
  const objectName=first?`OPF list paragraph ${context.listMarkers.size+1}`:undefined;
1060
1092
  if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
1061
1093
  const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1062
- const runs=richLineRuns(line,color);
1094
+ const runs=richLineRuns(line,color,context);
1063
1095
  if(!runs.length)runs.push({text:'',options:{}});
1064
1096
  // Keep paragraph intent identical across runs. ZIP normalization below
1065
1097
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
@@ -1128,6 +1160,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1128
1160
  h: region.h,
1129
1161
  altText: assetAlt(asset, presentation)
1130
1162
  });
1163
+ return objectName;
1131
1164
  }
1132
1165
 
1133
1166
  function addChartPayload(slide, chart, region, context) {
@@ -1190,8 +1223,10 @@ function addTablePayload(slide, table, region, context, options, path) {
1190
1223
  const rows = layout.rows.map(row => row.cells.map(cell => {
1191
1224
  const {header,rich,fit} = cell;
1192
1225
  const cellStyle = cell.style ?? {};
1193
- const baseFill = (cellStyle.fill ?? (header ? '#' + context.colors.accent : '#' + context.colors.surface)).replace(/^#/, '');
1194
- const baseColor = (cellStyle.color ?? textColorForFill('#' + baseFill, header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1226
+ const defaultFillHex = header ? context.colors.accent : context.colors.surface;
1227
+ const baseFill = exportColor(cellStyle.fill ?? defaultFillHex, context, defaultFillHex);
1228
+ const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
1229
+ const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
1195
1230
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1196
1231
  const text = stringifyText(cell.value), style = cell.textStyle;
1197
1232
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1199,7 +1234,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1199
1234
  const run = typeof value === 'string' ? {text:value} : value;
1200
1235
  const fragment = fragments.find(item => item.runIndex === index);
1201
1236
  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);
1202
- const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
1237
+ const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
1203
1238
  const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1204
1239
  const runOptions = {
1205
1240
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
@@ -1277,12 +1312,13 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1277
1312
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1278
1313
  }
1279
1314
  for (const [index, line] of fit.lines.entries()) {
1280
- if (!line&&!config.heading&&!config.sourceText&&!config.timeline) continue;
1315
+ if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
1281
1316
  const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1282
1317
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1283
- const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:undefined;
1318
+ const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
1284
1319
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1285
1320
  else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
1321
+ else if(config.furniture)context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1286
1322
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1287
1323
  const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
1288
1324
  slide.addText(line, {
@@ -1296,6 +1332,27 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1296
1332
  }
1297
1333
  }
1298
1334
 
1335
+ async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
1336
+ if(!layout){
1337
+ if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
1338
+ const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex);
1339
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1340
+ return;
1341
+ }
1342
+ for(const [index,part]of layout.parts.entries()){
1343
+ if(part.type==='image'){
1344
+ const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
1345
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
1346
+ if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
1347
+ }else{
1348
+ if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1349
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index}});
1350
+ }
1351
+ }
1352
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex);
1353
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1354
+ }
1355
+
1299
1356
  function addCodePayload(slide, value, layout, region, context, path) {
1300
1357
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1301
1358
  for (const part of layout.parts) {
@@ -1452,9 +1509,9 @@ function textRuns(value, context, fallbackFontSize) {
1452
1509
  options: {
1453
1510
  bold: run?.bold,
1454
1511
  italic: run?.italic,
1455
- underline: run?.underline ? { color: normalizeHex(run.color ?? context.colors.text) } : undefined,
1512
+ underline: run?.underline ? { color: normalizeHex(exportColor(run?.color, context, context.colors.text)) } : undefined,
1456
1513
  strike: run?.strikethrough ? "sngStrike" : undefined,
1457
- color: normalizeHex(run?.color ?? context.colors.text),
1514
+ color: normalizeHex(exportColor(run?.color, context, context.colors.text)),
1458
1515
  fontFace: run?.fontFamily ?? context.fonts.body,
1459
1516
  fontSize: run?.fontSize ?? fallbackFontSize,
1460
1517
  superscript: run?.superscript,
@@ -1630,18 +1687,19 @@ function resolveDimensions(value) {
1630
1687
  }
1631
1688
 
1632
1689
  function resolveBackground(value, colorScheme) {
1690
+ const fallback = "FFFFFF";
1633
1691
  if (typeof value === "string") {
1634
- if (value.startsWith("#")) return normalizeHex(value);
1635
- return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF");
1692
+ if (value.startsWith("#")) return normalizeHex(value, fallback);
1693
+ return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
1636
1694
  }
1637
1695
  if (isPlainObject(value)) {
1638
- if (value.type === "solid" && value.color) return normalizeHex(value.color);
1696
+ if (value.type === "solid" && value.color) return normalizeHex(value.color, fallback);
1639
1697
  if (value.type === "theme" && value.slot) {
1640
- return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF");
1698
+ return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
1641
1699
  }
1642
- if (value.backgroundColor) return normalizeHex(value.backgroundColor);
1700
+ if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
1643
1701
  }
1644
- return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF");
1702
+ return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
1645
1703
  }
1646
1704
 
1647
1705
  function resolveFonts(fontScheme) {
@@ -1655,8 +1713,8 @@ function readableTextColor(background, colorScheme) {
1655
1713
  : normalizeHex(colorScheme.text ?? colorScheme.dark1 ?? "#0F172A");
1656
1714
  }
1657
1715
 
1658
- function normalizeHex(value) {
1659
- if (typeof value !== "string") return "000000";
1716
+ function normalizeHex(value, fallback = "000000") {
1717
+ if (typeof value !== "string") return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
1660
1718
  const raw = value.trim().replace(/^#/, "");
1661
1719
  if (/^[0-9a-fA-F]{3}$/.test(raw)) {
1662
1720
  return raw.split("").map((char) => char + char).join("").toUpperCase();
@@ -1664,7 +1722,7 @@ function normalizeHex(value) {
1664
1722
  if (/^[0-9a-fA-F]{6,8}$/.test(raw)) {
1665
1723
  return raw.slice(0, 6).toUpperCase();
1666
1724
  }
1667
- return raw.toUpperCase();
1725
+ return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
1668
1726
  }
1669
1727
 
1670
1728
  function isDarkHex(value) {
@@ -1692,6 +1750,7 @@ async function normalizePptxZip(raw, context) {
1692
1750
  attachHeadingTags(entries,context.headingTags);
1693
1751
  attachPlainTextTags(entries,context.plainTextTags);
1694
1752
  attachTimelineTags(entries,context.timelineTags);
1753
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
1695
1754
  for(const [part,bytes]of Object.entries(entries)){
1696
1755
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
1697
1756
  const relationships=parseRelationships(entries,part);
@@ -1852,7 +1911,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1852
1911
  for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
1853
1912
  const border = style.borders?.[edge];
1854
1913
  if (!border) continue;
1855
- const fill = border.width === 0 ? '<a:noFill/>' : nativeBackgroundFill({type:'solid',color:border.color},{width:1,height:1});
1914
+ const borderHex = exportColor(border.color, context, context.colors.border);
1915
+ const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
1916
+ const fill = border.width === 0 ? '<a:noFill/>' : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, context.colors.border);
1856
1917
  const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
1857
1918
  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}>`;
1858
1919
  const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "sideEffects": false,
38
38
  "scripts": {
39
39
  "build": "node scripts/build.mjs",
40
- "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 && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs",
41
- "test": "npm run build && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
40
+ "typecheck": "node --check src/index.js && node --check src/color-ref.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 && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check test/furniture-provenance.mjs",
41
+ "test": "npm run build && npm run test:furniture && node test/color-ref-export.mjs && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
42
42
  "validate": "node scripts/validate-package.mjs",
43
43
  "test:packed": "node test/packed-install.mjs",
44
44
  "test:compare-published": "node test/compare-published.mjs",
@@ -49,17 +49,18 @@
49
49
  "test:code": "node test/shared-code.mjs && node test/code-provenance.mjs",
50
50
  "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
51
51
  "test:font-variants": "node test/font-variants.mjs",
52
- "test:timeline": "node test/shared-timeline.mjs"
52
+ "test:timeline": "node test/shared-timeline.mjs",
53
+ "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs"
53
54
  },
54
55
  "dependencies": {
55
- "@openpresentation/opf": "^0.10.0",
56
+ "@openpresentation/opf": "^0.11.0",
56
57
  "fast-xml-parser": "^5.8.0",
57
58
  "fflate": "^0.8.3",
58
- "jszip": "3.10.1",
59
+ "jszip": "3.10.2",
59
60
  "sharp": "0.35.4"
60
61
  },
61
62
  "peerDependencies": {
62
- "@openpresentation/opf-render": "^0.8.0"
63
+ "@openpresentation/opf-render": "^0.8.1"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "@openpresentation/opf-render": {
@@ -67,7 +68,7 @@
67
68
  }
68
69
  },
69
70
  "devDependencies": {
70
- "@openpresentation/opf-render": "0.8.0",
71
+ "@openpresentation/opf-render": "0.8.1",
71
72
  "esbuild": "0.28.2",
72
73
  "playwright": "1.63.0"
73
74
  },