@openpresentation/opf-pptx 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OPF PPTX
2
2
 
3
- Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
3
+ Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
4
4
 
5
5
  Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve 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.
6
6
 
@@ -19,8 +19,14 @@
19
19
  // | {form: 'blocks', blocks: Node[]}
20
20
  // | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
21
21
  // Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
22
- // | {t: 'leaf', k, id?, ext?, typed?, box?} // box = [x, y, w, h] reference px, 1 decimal
22
+ // | {t: 'leaf', k, id?, ext?, typed?, box?, lines?} // box = [x, y, w, h] reference px, 1 decimal
23
23
  // typed = true when the authored block spelled out its `type`
24
+ // lines = N (>= 2) when a rich-text `text` payload exported as N native line
25
+ // shapes that are soft wraps of one paragraph (no hard break between
26
+ // them); import joins those N imported lines back into the one payload
27
+ //
28
+ // Native line shapes of rich text carry no names or tags, so the soft wraps are
29
+ // recorded here as a count; a wrapped payload with any hard break is not marked.
24
30
  //
25
31
  // Tags are untrusted input: every field is type-, count- and depth-checked
26
32
  // before use, and the rebuilt slide still has to validate with the document.
@@ -48,6 +54,37 @@ export const BOX_TOLERANCE = 3;
48
54
  const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' : field;
49
55
  const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
50
56
  const round1 = value => Math.round(value * 10) / 10;
57
+ export const MAX_WRAPPED_LINES = 1000;
58
+
59
+ /**
60
+ * The number of native line shapes a rich-text `text` item exported as, when
61
+ * those lines are consecutive soft wraps of the authored runs: the line
62
+ * fragments, concatenated in order, are exactly the concatenated run text, so
63
+ * no line boundary stands for a hard break (a newline, a blank line, collapsed
64
+ * or dropped characters). Undefined for a single line or when that cannot be
65
+ * shown: nothing then marks the leaf and import keeps the separate blocks.
66
+ */
67
+ export function softWrappedLines(item) {
68
+ const lines = item?.text?.richLines;
69
+ if (item?.field !== 'text' || !Array.isArray(lines) || lines.length < 2 || lines.length > MAX_WRAPPED_LINES) return undefined;
70
+ const runs = Array.isArray(item.value) ? item.value : typeof item.value === 'string' ? [item.value] : null;
71
+ if (!runs) return undefined;
72
+ let source = '';
73
+ for (const run of runs) {
74
+ const text = typeof run === 'string' ? run : object(run) ? run.text : undefined;
75
+ if (typeof text !== 'string') return undefined;
76
+ source += text;
77
+ }
78
+ let position = 0;
79
+ for (const line of lines) {
80
+ if (!Array.isArray(line?.fragments) || !line.fragments.length) return undefined;
81
+ for (const fragment of line.fragments) {
82
+ if (typeof fragment?.text !== 'string' || !source.startsWith(fragment.text, position)) return undefined;
83
+ position += fragment.text.length;
84
+ }
85
+ }
86
+ return position === source.length ? lines.length : undefined;
87
+ }
51
88
 
52
89
  function blockKind(block) {
53
90
  if (typeof block.type === 'string' && block.type !== 'group') return CONTENT_KINDS.includes(block.type) ? block.type : null;
@@ -67,8 +104,12 @@ function blockKind(block) {
67
104
  */
68
105
  export function contentTopology(slide, items, slideIndex, report = () => {}) {
69
106
  if (!object(slide)) return undefined;
70
- const boxes = new Map();
71
- for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) boxes.set(item.path, item.box);
107
+ const boxes = new Map(), wrapped = new Map();
108
+ for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) {
109
+ boxes.set(item.path, item.box);
110
+ const lines = softWrappedLines(item);
111
+ if (lines) wrapped.set(item.path, lines);
112
+ }
72
113
  const base = `slides.${slideIndex}`;
73
114
  let nodes = 0;
74
115
  const fail = reason => { throw new TopologyError(reason); };
@@ -103,6 +144,7 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
103
144
  const leaf = identity(block, {t: 'leaf', k: kind}, path);
104
145
  const box = leafBox(path);
105
146
  if (box) leaf.box = box;
147
+ if (kind === 'text' && wrapped.has(`${path}.text`)) leaf.lines = wrapped.get(`${path}.text`);
106
148
  return leaf;
107
149
  };
108
150
  try {
@@ -117,7 +159,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
117
159
  // on one slide compose as one item each (`slides.N.<field>`).
118
160
  const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
119
161
  const box = boxes.get(`${base}.${field}`);
120
- return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1)} : {field};
162
+ const lines = field === 'text' ? wrapped.get(`${base}.text`) : undefined;
163
+ return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1), ...(lines ? {lines} : {})} : {field};
121
164
  });
122
165
  if (!fields.length) return undefined;
123
166
  if (fields.length === 1) return {form: 'root', ...fields[0]};
@@ -136,9 +179,10 @@ class TopologyError extends Error {}
136
179
 
137
180
  const validBox = value => value === undefined || (Array.isArray(value) && value.length === 4 && value.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) < 1e7));
138
181
  const validId = value => value === undefined || (typeof value === 'string' && value.length <= MAX_ID_LENGTH);
139
- const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box);
182
+ const validLines = value => value === undefined || (Number.isSafeInteger(value) && value >= 2 && value <= MAX_WRAPPED_LINES);
183
+ const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validLines(value.lines) && (value.lines === undefined || value.field === 'text');
140
184
  /** The root payload leaves of a root-form record: [{field, box?}]. */
141
- export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box}];
185
+ export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines}];
142
186
 
143
187
  /** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
144
188
  export function validateTopology(value) {
@@ -161,6 +205,7 @@ export function validateTopology(value) {
161
205
  if (!CONTENT_KINDS.includes(item.k)) throw Error('Unknown content kind.');
162
206
  if (item.comp !== undefined) throw Error('A content leaf has no composition.');
163
207
  if (!validBox(item.box)) throw Error('Invalid content box.');
208
+ if (!validLines(item.lines) || (item.lines !== undefined && item.k !== 'text')) throw Error('Invalid wrapped line count.');
164
209
  };
165
210
  switch (value.form) {
166
211
  case 'root': {
@@ -172,6 +217,7 @@ export function validateTopology(value) {
172
217
  }
173
218
  if (!ROOT_PAYLOAD_FIELDS.includes(value.field)) throw Error('Unknown root payload field.');
174
219
  if (!validBox(value.box)) throw Error('Invalid content box.');
220
+ if (!validLines(value.lines) || (value.lines !== undefined && value.field !== 'text')) throw Error('Invalid wrapped line count.');
175
221
  return value;
176
222
  }
177
223
  case 'blocks':
@@ -202,6 +248,25 @@ const containsOrigin = (box, bounds) => bounds.x >= box[0] - BOX_TOLERANCE && bo
202
248
  const contains = (box, bounds) => containsOrigin(box, bounds)
203
249
  && bounds.x + bounds.width <= box[0] + box[2] + BOX_TOLERANCE && bounds.y + bounds.height <= box[1] + box[3] + BOX_TOLERANCE;
204
250
 
251
+ const textRuns = value => typeof value === 'string' || (Array.isArray(value) && value.length > 0 && value.every(run => object(run) && typeof run.text === 'string'));
252
+ const sameRunStyle = (a, b) => JSON.stringify({...a, text: ''}) === JSON.stringify({...b, text: ''});
253
+ // The lines' text joined in order. Runs are never invented or dropped; at a seam the
254
+ // last run of one line and the first of the next merge when they differ only in text
255
+ // (a run the wrap cut in two), the same way a rejoined list does.
256
+ function joinWrappedText(parts) {
257
+ if (parts.every(part => typeof part === 'string')) return parts.join('');
258
+ const joined = [];
259
+ for (const part of parts) {
260
+ const runs = typeof part === 'string' ? [{text: part}] : part;
261
+ runs.forEach((run, position) => {
262
+ const last = joined.at(-1);
263
+ if (position === 0 && last && sameRunStyle(last, run)) last.text += run.text;
264
+ else joined.push({...run});
265
+ });
266
+ }
267
+ return joined;
268
+ }
269
+
205
270
  /**
206
271
  * Rebuild the authored content form from the imported flat `blocks` and their
207
272
  * native `bounds` (reference px, one entry per block, null when unknown).
@@ -243,6 +308,18 @@ export function rebuildContent(topology, blocks, bounds) {
243
308
  leaf.payload = merged;
244
309
  continue;
245
310
  }
311
+ // Rich text that wrapped exports one native line shape per soft wrap and
312
+ // imports as one text block per line. The record counted those lines at
313
+ // export (see softWrappedLines) and the slide's shapes are unchanged since
314
+ // (the structure hash was checked before this rebuild), so exactly that many
315
+ // text blocks in the leaf are its consecutive lines: they rejoin in reading
316
+ // order, with no separator, because a soft wrap deletes no characters.
317
+ if (leaf.kind === 'text' && leaf.node.lines === leaf.matches.length && leaf.matches.every(index => blocks[index]?.type === 'text' && textRuns(blocks[index].text))) {
318
+ const ordered = leaf.matches.map((index, position) => ({index, y: bounds[index].y, position})).sort((a, b) => a.y - b.y || a.position - b.position).map(entry => entry.index);
319
+ leaf.payload = {...blocks[ordered[0]], text: joinWrappedText(ordered.map(index => blocks[index].text))};
320
+ leaf.matches = [ordered[0]];
321
+ continue;
322
+ }
246
323
  return {reason: `${leaf.matches.length} blocks lie in one stored content box`};
247
324
  }
248
325
  const ids = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",