@openpresentation/opf-pptx 0.27.0 → 0.28.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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  PPTX 0.11.4 depends on published [`@openpresentation/opf@^0.11.3`](https://www.npmjs.com/package/@openpresentation/opf) (reference layer: ColorRef, document `variables`, exported `resolveColorRef()`). The optional renderer peer is [`@openpresentation/opf-render@^0.11.0`](https://www.npmjs.com/package/@openpresentation/opf-render) so editor 0.10.0 + render 0.11.0 + pptx 0.11.0 install together. 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. The deck color scheme is written as the theme `clrScheme`, and named slot and role colors (text, table cells and borders, hyperlinks, backgrounds) are written as `schemeClr` where the deck theme holds exactly that color; card, timeline and media colors, literal hex, variables, translucent colors and slide-override colors stay literal `srgbClr`.
6
6
 
7
- Since RR-17 (opf-pptx#162) the vendored engine is [pptxgenjs-plus](https://github.com/lofcz/pptxgenjs-plus) 4.3.4, the maintained MIT fork of PptxGenJS (same public API and default export): `vendor/pptxgenjs/pptxgen.es.js` is the exact, unmodified `package/dist/pptxgen.es.js` of the npm archive, with its MIT license, the archive's SHA-512 integrity, per-file SHA-256 hashes and the source commit of its npm SLSA provenance (`lofcz/pptxgenjs-plus@ecfa5086`) in `UPSTREAM.json`. Its imports are declared directly and pinned: `@node-projects/jszip` 4.3.0 (replacing `jszip` 3.10.2), `@rgrove/parse-xml` 4.2.3 and `pako` 3.0.2. `@node-projects/jszip` declares `@types/node` as a runtime dependency (type declarations, no code), so it is installed too. opf-pptx's own package reading and writing stays on `fflate`. Every output change and how it was measured is in [docs/pptxgenjs-plus-migration.md](docs/pptxgenjs-plus-migration.md); `src/vendor-compat.js` keeps the PptxGenJS 4.0.1 output where opf-pptx's contracts need it.
7
+ Since RR-17 (opf-pptx#162) the vendored engine is [pptxgenjs-plus](https://github.com/lofcz/pptxgenjs-plus) 4.3.4, the maintained MIT fork of PptxGenJS (same public API and default export): `vendor/pptxgenjs/pptxgen.es.js` is the exact, unmodified `package/dist/pptxgen.es.js` of the npm archive, with its MIT license, the archive's SHA-512 integrity, per-file SHA-256 hashes and the source commit of its npm SLSA provenance (`lofcz/pptxgenjs-plus@ecfa5086`) in `UPSTREAM.json`. Its imports are declared directly and pinned: `@node-projects/jszip` 4.3.0 (replacing `jszip` 3.10.2), `@rgrove/parse-xml` 5.0.0 and `pako` 3.0.2. `@node-projects/jszip` declares `@types/node` as a runtime dependency (type declarations, no code), so it is installed too. opf-pptx's own package reading and writing stays on `fflate`. Every output change and how it was measured is in [docs/pptxgenjs-plus-migration.md](docs/pptxgenjs-plus-migration.md); `src/vendor-compat.js` keeps the PptxGenJS 4.0.1 output where opf-pptx's contracts need it.
8
8
 
9
9
  Browser builds of the engine's ZIP dependency (RR-20, 0.13.1): `@node-projects/jszip` 4.3.0's `lib/support.js` probes `require("readable-stream")` inside try/catch. Node ESM has no `require`, so the probe always fails there, but bundlers follow the literal: when the consumer's tree holds readable-stream 2 (for example through jszip 3, as in opf-editor's packed consumer), esbuild bundles it and fails on `events` and `buffer`, and Turbopack pulls it in with Node polyfills. `vendor/jszip/index-min.js` is the exact, unmodified `package/lib/index-min.js` of the pinned npm archive (the package's own prepublish bundle of `lib/`, which esbuild 0.28.2 reproduces byte for byte; it calls the probe through a wrapper bundlers do not follow), with its license (`LICENSE.md`, MIT or GPL-3.0-or-later; opf-pptx uses it under MIT), the archive's SHA-512 integrity and per-file SHA-256 hashes in `UPSTREAM.json`. The browser field of `vendor/pptxgenjs/package.json` maps the engine's `@node-projects/jszip` import to it (esbuild, webpack and Turbopack honour it); Node ignores the browser field and keeps the installed package. Every build (`scripts/verify-vendor.mjs`) requires the vendored version to equal the pinned dependency and the lockfile integrity, and its bytes to equal the installed package's bundle, so a dependency bump without re-vendoring fails. Refresh it by copying `node_modules/@node-projects/jszip/lib/index-min.js` and `LICENSE.md` after `npm ci` and updating `UPSTREAM.json`. `test/zip-bundle-boundary.mjs` (offline) and the browser suite `test/packed-bundle-browser.mjs` (packed tarball installed next to readable-stream, bundled with esbuild, the core examples exported in Chromium byte-identical to Node) guard it.
10
10
 
@@ -375,7 +375,9 @@ export function listLineRoles(items, omit = () => {}) {
375
375
  const lists = [];
376
376
  for (const item of items ?? []) {
377
377
  if (!['items', 'bullets'].includes(item?.field) || !Array.isArray(item?.text?.listEntries)) continue;
378
- if (!item.text.listEntries.some(entry => entry.description?.richLines?.length)) continue;
378
+ // A list without markers (design.listBullet: none) records its roles too: without a bullet, the first line of an entry is
379
+ // only known from this record.
380
+ if (!item.text.listEntries.some(entry => entry.description?.richLines?.length || entry.marker?.none)) continue;
379
381
  const lines = [];
380
382
  item.text.listEntries.forEach((entry, index) => {
381
383
  for (const [role, text] of [['text', entry.text], ['description', entry.description]])
package/dist/index.js CHANGED
@@ -1480,11 +1480,15 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
1480
1480
  const merged = new Map(), consumed = new Set();
1481
1481
  for (const [path, entries] of lists) {
1482
1482
  entries.sort((a, b) => a.line - b.line);
1483
- if (!entries[0].item.paragraphs[0].bullet) continue;
1484
1483
  const expectedRoles = roles?.get(path);
1485
- const validRoles = expectedRoles && expectedRoles.length === entries.length && entries.every(({item, line}, index) => line === index && Boolean(item.paragraphs[0].bullet) === expectedRoles[index][2]);
1484
+ // design.listBullet: none writes no bullet at all (a:buNone): the list is unambiguous only through its recorded roles, whose
1485
+ // first lines start the entries. Without that record the shapes stay as they are.
1486
+ const unbulleted = entries.every(({item}) => !item.paragraphs[0].bullet);
1487
+ if (!entries[0].item.paragraphs[0].bullet && !(unbulleted && expectedRoles?.[0]?.[2])) continue;
1488
+ const validRoles = expectedRoles && expectedRoles.length === entries.length && entries.every(({item, line}, index) => line === index && (unbulleted || Boolean(item.paragraphs[0].bullet) === expectedRoles[index][2]));
1486
1489
  const lineRoles = validRoles ? expectedRoles : null;
1487
1490
  if (expectedRoles && !validRoles) report({code: 'list-line-role-changed', message: `The line topology of ${path} changed; list roles were not restored.`});
1491
+ if (unbulleted && !validRoles) continue;
1488
1492
  const look = runs?.get(path), textLook = cleanBase(look?.text), descriptionLook = cleanBase(look?.description);
1489
1493
  // The line's runs without the inherited look, or the paragraph's own richText when there is no record for this kind of line.
1490
1494
  // Each run is cleaned on its own and none merge, so the runs stay the slots the run colour record counts (src/run-colors.js);
@@ -1512,7 +1516,7 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
1512
1516
  const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
1513
1517
  const gap = recorded?.get(line);
1514
1518
  const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
1515
- if (paragraph.bullet) { paragraphs.push({...paragraph, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
1519
+ if (unbulleted ? lineRoles?.[line][2] : paragraph.bullet) { paragraphs.push({...paragraph, bullet: true, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
1516
1520
  else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
1517
1521
  else if (lineRoles ? lineRoles[line][1] === 'description' : paragraph.maxFontSize < size - 0.01) {
1518
1522
  const text = own(paragraph, descriptionLook) ?? paragraph.text;
@@ -2554,7 +2558,9 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
2554
2558
  for(const entry of fit.listEntries){
2555
2559
  const addLines=(text,box,color,native,withBullet)=>{
2556
2560
  text.richLines.forEach((line,index)=>{
2557
- const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
2561
+ // design.listBullet: none (entry.marker.none): the first line has no marker, no inset and no hanging indent (a:buNone); core
2562
+ // already moved a nested level's text box, so the paragraph carries no indent level.
2563
+ const first=withBullet&&index===0,markerless=first&&entry.marker.none===true,level=Math.min(8,entry.level),inset=first&&!markerless?entry.marker.indent*(level+1):0;
2558
2564
  // Right to left (RR-05): the bullet column is at the right, so the first line's box extends right to hold it and the paragraph
2559
2565
  // is rtl with its text aligned to the right; marL/indent are the start-side margin and hanging indent in PowerPoint.
2560
2566
  const entryRtl=entry.direction==='rtl';
@@ -2563,8 +2569,8 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
2563
2569
  const lineAlign=physicalAlignment('left',lineDirection);
2564
2570
  const objectName=`OPF list ${path} line ${lineNumber++}`;
2565
2571
  const number=first?entry.marker.number:undefined;
2566
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
2567
- const paragraph=first?{bullet:number?{type:'number',style:autoNumScheme(number.style,number.suffix),startAt:number.value,indent:entry.marker.indent*.75}:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
2572
+ if(first&&!markerless)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
2573
+ const paragraph=first&&!markerless?{bullet:number?{type:'number',style:autoNumScheme(number.style,number.suffix),startAt:number.value,indent:entry.marker.indent*.75}:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
2568
2574
  const runs=richLineRuns(line,color,native,context,path);
2569
2575
  if(!runs.length)runs.push({text:'',options:{}});
2570
2576
  // Keep paragraph intent identical across runs. ZIP normalization below
@@ -47,7 +47,7 @@ function planOne(item, geometry, automatic, reject) {
47
47
  let values, numbering;
48
48
  if (list) {
49
49
  const entries = item.text?.listEntries;
50
- if (item.bulletImage || !entries?.length || entries.some(entry => entry.level !== 0 || entry.description) ||
50
+ if (item.bulletImage || !entries?.length || entries.some(entry => entry.level !== 0 || entry.description || entry.marker?.none) ||
51
51
  item.value.some(entry => entry && !Array.isArray(entry) && typeof entry === 'object' && Object.keys(entry).some(key => key !== 'text' && key !== 'level'))) {
52
52
  reject('unsupported-list'); return undefined;
53
53
  }
@@ -3,6 +3,7 @@ import {isValidFormat} from './format-check.js';
3
3
  import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
4
4
  import {sourceLineParagraphs} from './text-provenance.js';
5
5
  import {cleanBase,headingValue,joinRichLines,unwrapQuoteRuns} from './rich-heading.js';
6
+ import {unwrapQuoteMarks} from '@openpresentation/opf/composition';
6
7
 
7
8
  // FF-57: a `quote` payload exports as native text lines (a body part and an optional footer part). Each line shape carries an
8
9
  // OPF_QUOTE_V1 tag, so an unchanged export imports back as {quote: ...} instead of loose text blocks. Like timelines, only topology
@@ -11,10 +12,11 @@ import {cleanBase,headingValue,joinRichLines,unwrapQuoteRuns} from './rich-headi
11
12
  // FA-12: a quote with a `photo` also exports one native picture (an ellipse), tagged with the same group; import restores `photo` from
12
13
  // that picture's current bytes and description, and `role` from the footer.
13
14
  const TAG='OPF_QUOTE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
14
- const SEPARATOR=' - ',FIELDS=['attribution','role','source'];
15
+ // Core joins the source with an en dash (WI-4); a file exported before then holds a hyphen, which import still reads.
16
+ const SEPARATOR=' \u2013 ',LEGACY_SEPARATOR=' - ',FIELDS=['attribution','role','source'];
15
17
  // Every non-empty footer field list, in core's order (attribution, role, source).
16
18
  const FOOTERS=['["attribution"]','["role"]','["source"]','["attribution","role"]','["attribution","source"]','["role","source"]','["attribution","role","source"]'];
17
- // Core joins the role to the line above with a line break and the source with " - ".
19
+ // Core joins the role to the line above with a line break and the source with " \u2013 ".
18
20
  const separatorBefore=field=>field==='role'?'\n':SEPARATOR;
19
21
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
20
22
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
@@ -22,7 +24,7 @@ export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,T
22
24
  export const quotePhotoName=group=>`OPF quote photo ${group}`;
23
25
 
24
26
  // The manifest rides on the first body line. `footer` lists which fields the footer part joins (core writes the attribution, the role
25
- // on its own line and the source after " - ", skipping an absent field) and, from two fields on, where each separator starts, so an
27
+ // on its own line and the source after " \u2013 ", skipping an absent field) and, from two fields on, where each separator starts, so an
26
28
  // unchanged footer splits back exactly. `photo` records that a headshot picture belongs to the quote.
27
29
  export function quoteManifest(value,layout) {
28
30
  const shorthand=typeof value==='string',fields=shorthand?[]:FIELDS.filter(field=>value[field]);
@@ -55,7 +57,7 @@ function validateManifest(manifest) {
55
57
  return count;
56
58
  }
57
59
 
58
- const unwrap=text=>text.length>=2&&text.startsWith('"')&&text.endsWith('"')?text.slice(1,-1):text;
60
+ const unwrap=unwrapQuoteMarks;
59
61
  // Split the current footer text back into fields. A separator whose recorded position no longer matches is searched for from the end of
60
62
  // the previous field; a footer with a missing separator keeps everything as the attribution.
61
63
  function footerFields(text,footer,report) {
@@ -64,8 +66,10 @@ function footerFields(text,footer,report) {
64
66
  const out={};let from=0;
65
67
  for(let index=1;index<fields.length;index++){
66
68
  const separator=separatorBefore(fields[index]),recorded=footer.splits[index-1];
67
- const at=recorded>=from&&text.startsWith(separator,recorded)?recorded:text.indexOf(separator,from);
68
- if(at<0){
69
+ const legacy=separator===SEPARATOR?LEGACY_SEPARATOR:separator,both=[separator,legacy];
70
+ const found=both.find(candidate=>recorded>=from&&text.startsWith(candidate,recorded));
71
+ const at=found!==undefined?recorded:Math.min(...both.map(candidate=>text.indexOf(candidate,from)).filter(position=>position>=0),Infinity);
72
+ if(at===Infinity){
69
73
  report({code:'quote-footer-merged',message:'The quote footer no longer separates its attribution, role and source; the current text is kept as the attribution.'});
70
74
  return {attribution:text};
71
75
  }
@@ -7,6 +7,7 @@
7
7
  // are joined with the boundaries their tags record; soft wraps insert nothing and hard boundaries keep their separator.
8
8
 
9
9
  import {splitWeightFace} from './font-weights.js';
10
+ import {isWrappedInQuoteMarks, unwrapQuoteMarks} from '@openpresentation/opf/composition';
10
11
 
11
12
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
12
13
  // The run properties that describe how a heading looks. `link` and `text` are never defaults.
@@ -88,11 +89,11 @@ export function headingValue(parts, base) {
88
89
  return merged.map(run => Object.keys(run).length === 1 ? run.text : run);
89
90
  }
90
91
 
91
- /** Remove the opening and closing quotation mark an exported quote body carries (core joins them to the first and last run). */
92
+ /** Remove the opening and closing quotation mark an exported quote body carries (core joins them to the first and last run; the pair follows the deck language, or is the older ASCII pair). */
92
93
  export function unwrapQuoteRuns(value) {
93
- if (!Array.isArray(value)) return typeof value === 'string' && value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
94
+ if (!Array.isArray(value)) return typeof value === 'string' ? unwrapQuoteMarks(value) : value;
94
95
  const text = run => typeof run === 'string' ? run : run.text;
95
- if (!value.length || !text(value[0]).startsWith('"') || !text(value.at(-1)).endsWith('"') || (value.length === 1 && text(value[0]).length < 2)) return value;
96
+ if (!value.length || !isWrappedInQuoteMarks(text(value[0])[0] + text(value.at(-1)).at(-1)) || (value.length === 1 && text(value[0]).length < 2)) return value;
96
97
  const runs = value.map(run => typeof run === 'string' ? run : {...run});
97
98
  const edit = (index, change) => {
98
99
  const run = runs[index];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,16 +38,16 @@
38
38
  "sideEffects": false,
39
39
  "dependencies": {
40
40
  "@node-projects/jszip": "4.3.0",
41
- "@rgrove/parse-xml": "4.2.3",
41
+ "@rgrove/parse-xml": "5.0.0",
42
42
  "fast-xml-parser": "^5.8.0",
43
43
  "fflate": "^0.8.3",
44
44
  "pako": "3.0.2",
45
45
  "sharp": "0.35.5",
46
- "@openpresentation/gallery": "^0.27.0",
47
- "@openpresentation/opf": "^0.27.0"
46
+ "@openpresentation/gallery": "^0.28.0",
47
+ "@openpresentation/opf": "^0.28.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@openpresentation/opf-render": "^0.27.0"
50
+ "@openpresentation/opf-render": "^0.28.0"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@openpresentation/opf-render": {
@@ -73,8 +73,8 @@
73
73
  "@expo-google-fonts/tinos": "0.4.2",
74
74
  "@resvg/resvg-js": "2.6.2",
75
75
  "esbuild": "0.28.2",
76
- "playwright": "1.64.0",
77
- "@openpresentation/opf-render": "0.27.0"
76
+ "playwright": "1.64.1",
77
+ "@openpresentation/opf-render": "0.28.0"
78
78
  },
79
79
  "imports": {
80
80
  "#image-fallback": {
@@ -109,5 +109,5 @@
109
109
  "test:native-text": "node test/native-text-whitespace.mjs",
110
110
  "test:determinism": "node test/determinism.mjs"
111
111
  },
112
- "gitHead": "f90536d0e9a7a16b25aa718e03ced4e00a7316e1"
112
+ "gitHead": "abc56634a584db38a305a27edc3bc76b4116637b"
113
113
  }