@openpresentation/opf-pptx 0.7.0 → 0.8.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.
@@ -8,9 +8,11 @@ The inspected PptxGenJS 4.0.1 ESM/CommonJS distributions do not import `image-si
8
8
 
9
9
  Every build checks the vendored code and license against `UPSTREAM.json`. `node scripts/vendor-pptxgenjs.mjs` is an explicit maintainer command that fetches the pinned official npm archive, verifies its SHA-512 integrity and reads only the named distribution/license entries. It is never run by builds or installations. Review the upstream source, actual imports, license and audit before changing versions; retain tests and reassess [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). Do not accept npm's incompatible PptxGenJS downgrade suggestion.
10
10
 
11
+ Package normalization removes PptxGenJS 4.0.1 content-type declarations for generated slide-master parts that do not exist, without changing the vendor bytes or existing package parts. The smoke regression requires every override to target an actual part. This ports the independently audited correction from [commit 1363a65](https://github.com/OpenPresentation/opf-pptx/commit/1363a653c8b0e80c50d970bc30db3587bcf6f14e). Its notes-master SDK/native-order disagreement remains separate; the native ordering is unchanged.
12
+
11
13
  ## Compatible raster decoding
12
14
 
13
- Sharp 0.35.4 (Apache-2.0) is pinned for Node-only WebP-to-PNG conversion and loads lazily only when conversion is needed. Its platform packages include libvips and codec licenses; retain the upstream license files distributed by npm. Node 20.9+ and normal optional-platform dependency installation are required. Browser builds use the browser's decoder/canvas and a conditional package import; a bundling check rejects Sharp or the Node adapter in browser output. Ordinary/preserved image exports are tested with Sharp loading blocked.
15
+ Sharp 0.35.4 (Apache-2.0) is pinned for Node-only WebP-to-PNG conversion and loads lazily only when conversion is needed. Its platform packages include libvips and codec licenses; retain the upstream license files distributed by npm. This package requires Node 24 and normal optional-platform dependency installation. Browser builds use the browser's decoder/canvas and a conditional package import; a bundling check rejects Sharp or the Node adapter in browser output. Ordinary/preserved image exports are tested with Sharp loading blocked.
14
16
 
15
17
  The candidate's complete npm audit reports no known vulnerabilities in the installed graph. npm does not audit vendored source as an installed PptxGenJS package: maintainers must also review upstream PptxGenJS advisories and releases when updating or auditing the vendor directory. The browser bundler esbuild 0.28.2 and Playwright are development-only. No hosted service, paid API or telemetry was added.
16
18
 
package/README.md CHANGED
@@ -1,12 +1,24 @@
1
1
  # OPF PPTX
2
2
 
3
+ 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
+
5
+ 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.
6
+
3
7
  Pure local PowerPoint conversion tooling for Open Presentation Format documents. This repo owns the Phase 3 and Phase 4 toolkit lanes: OPF to PPTX export and PPTX to OPF import.
4
8
 
5
9
  Version 0.7.0's [shared-code integration](docs/code-roundtrip.md) preserves exact source/metadata through guarded native tags and editable lines. XML-forbidden code characters reject export with `invalid-code-text`, the OPF field path and UTF-16 offset; schema validation alone does not establish XML representability. Native source recovery does not reconstruct formatting, geometry or font theme.
6
10
 
11
+ Version 0.8.0 includes [shared metric integration](docs/metric-roundtrip.md). Native alignment and exact tested field recovery are implemented; tab-position and raster fidelity gates remain open.
12
+
13
+ The [native text verifier](docs/native-text-checks.md) has completed 24 separately bounded cases on each supported Node runtime against its recorded source baseline. All 72 imports and 96 original-text ink masks per runtime pass; parent-owned temporary fonts are removed after success, failure and timeout controls. Those historical results do not cover subsequent font-selection changes, edited-text reflow, arbitrary native fidelity or the unresolved chart and metric gates.
14
+
15
+ Version 0.8.0 physical-font selection consumes optional `fontFace` metadata on accepted text styles. The provider supplies the physical family and its boolean bold/italic style-link flags independently of logical numeric weight. This lets a resolved Roboto SemiBold or ExtraBold face keep its regular legacy-family style rather than requesting a second bold style. Providers without metadata retain the previous numeric-weight behavior; malformed metadata fails with `invalid-font-selection`. Use core 0.10.0 and renderer 0.8.0. [Verification and limits](docs/evidence/font-variants/README.md) distinguish native selection flags from actual Office paint.
16
+
17
+ Version 0.8.0 also measures `design.contentBox` cards through core's padded interior and exports editable rounded frames at their outer allocation. Native tags identify unchanged empty generated frames on reimport, with a `content-card-reflow` diagnostic: frame appearance and placement are not reconstructed. A name alone never hides a shape; edited, untagged or ambiguous frames use ordinary native import, retaining text or an unsupported-shape description. These source checks do not establish native PowerPoint raster fidelity.
18
+
7
19
  ## Scope
8
20
 
9
- Version 0.7.0 requires core 0.9.0 and uses renderer 0.7.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import returns editable text blocks and does not restore the original OPF quote structure, typography or readability policy. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
21
+ Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import returns editable text blocks and does not restore the original OPF quote structure, typography or readability policy. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
10
22
 
11
23
  - Package: `@openpresentation/opf-pptx`
12
24
  - Repository: `OpenPresentation/opf-pptx`
@@ -67,7 +79,7 @@ This pass did not require an OPF schema change. The deferred full OOXML placehol
67
79
  The first importer is mechanical and schema-compatible:
68
80
 
69
81
  - Presentation core properties map to OPF `name`, `description`, and `author`.
70
- - Slide text placeholders and large top-of-slide text boxes map to `title` and `subtitle` when recognizable.
82
+ - Native title/subtitle placeholders retain their roles. On slides without complete OPF heading tags, recognizable text-box positions and sizes provide a fallback. If any complete OPF heading role is recovered, untagged body text stays in `blocks[]` instead of being promoted into an absent heading role. Damaged tags retain visible text through ordinary import and diagnostics.
71
83
  - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
72
84
  - PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to basic OPF chart blocks.
73
85
  - Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
@@ -157,7 +169,7 @@ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens
157
169
 
158
170
  Set `imageFormat: "preserve"` to embed WebP unchanged when the receiving application supports it. Other image formats keep their existing export behavior. Conversion errors include the OPF image path; images above 40 megapixels are rejected before compatible conversion. Decoder differences can affect color/alpha rounding, so byte identity across platforms is not promised.
159
171
 
160
- Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 20.9 or later. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
172
+ Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 24. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
161
173
 
162
174
  `npm test` includes the Node pixel-reference cases and verifies browser bundling. To run the browser pixel checks, run `npm run build:browser-check`, serve this repository locally, and open `/artifacts/webp-fallback/browser/index.html`. The page reports 13 checks covering embedded PNG pixels, alpha, EXIF, the first animation frame, fit/crop, resolver calls and DOM canvas fallback. These are browser export checks, separate from the recorded Keynote viewing evidence.
163
175
 
@@ -218,3 +230,5 @@ Conditional borders keep the whole-table outer frame separate from interior hori
218
230
  Export consumes the same core geometry as SVG. It writes native merged cells and corrects PptxGenJS's padding-unit heuristic and missing dotted/transparent-border options in generated XML. Rich runs retain their own resolved opacity, including opaque overrides within a translucent cell. Tests inspect actual native XML and repeated conversions; native PowerPoint raster parity is not established.
219
231
 
220
232
  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
+
234
+ 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.
@@ -0,0 +1,51 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
+
4
+ const TAG='OPF_CARD_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
5
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
6
+ const decoder=new TextDecoder('utf-8',{fatal:true}),array=v=>v===undefined?[]:Array.isArray(v)?v:[v];
7
+ const canonical=value=>JSON.stringify(value,(_key,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item);
8
+
9
+ // A name alone is never sufficient to remove a shape during import. Bind the
10
+ // generated empty frame to its serialized properties through native shape tags.
11
+ export function attachCardTags(entries,names) {
12
+ if(!names.size)return;
13
+ const records=new Map();
14
+ for(const path of Object.keys(entries).filter(path=>/^ppt\/slides\/slide\d+\.xml$/.test(path))){
15
+ const tree=parser.parse(decoder.decode(entries[path]))['p:sld']['p:cSld']['p:spTree'];
16
+ for(const shape of array(tree['p:sp'])){
17
+ const name=shape['p:nvSpPr']?.['p:cNvPr']?.name;
18
+ if(names.has(name)){
19
+ if(records.has(name))throw Error('Duplicate generated card frame.');
20
+ records.set(name,{v:1,path:names.get(name),properties:shape['p:spPr'],style:shape['p:style']??null});
21
+ }
22
+ }
23
+ }
24
+ if(records.size!==names.size)throw Error('Missing generated card frame.');
25
+ attachTextTags(entries,records,TAG,'opfCard','card');
26
+ }
27
+
28
+ export function importCardFrames(shapes,paragraphs,relationships,entries,report) {
29
+ const consumed=new Set();
30
+ for(const [index,shape]of shapes.entries()){
31
+ const properties=shape['p:spPr'],identity=shape['p:nvSpPr']?.['p:cNvPr'],tags=[];
32
+ let unreadable=false;
33
+ for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
34
+ const rel=relationships.get(link['r:id']);
35
+ if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
36
+ try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
37
+ }
38
+ const candidates=tags.filter(tag=>tag.name?.toUpperCase()===TAG);
39
+ if(!candidates.length)continue;
40
+ try{
41
+ if(unreadable||candidates.length!==1||tags.some(tag=>/^OPF_/i.test(tag.name)&&tag.name.toUpperCase()!==TAG))throw Error('Ambiguous frame identity.');
42
+ const manifest=decodeTextTag(candidates[0].val);
43
+ if(manifest.v!==1||typeof manifest.path!=='string'||!manifest.path.startsWith('slides.')||identity?.name!==`OPF card ${manifest.path}`)throw Error('Invalid frame identity.');
44
+ if((paragraphs[index]??[]).some(p=>p.text.length)||Object.keys(identity).some(key=>!['id','name'].includes(key)))throw Error('Frame has native content or metadata.');
45
+ if(properties?.['a:prstGeom']?.prst!=='roundRect'||canonical(properties)!==canonical(manifest.properties)||canonical(shape['p:style']??null)!==canonical(manifest.style))throw Error('Frame styling or geometry changed.');
46
+ consumed.add(shape);
47
+ report({code:'content-card-reflow',message:'An unchanged tagged OPF content card was recognized as decoration. Its native frame styling and placement are not reconstructed in the reflowed document.'});
48
+ }catch{report({code:'invalid-card-provenance',message:'Edited, ambiguous or invalid card frame passed to ordinary native import, which retains its text or an unsupported-shape description. Inspect the original PPTX for frame appearance and metadata.'});}
49
+ }
50
+ return consumed;
51
+ }
@@ -0,0 +1,81 @@
1
+ import {unzipSync,zipSync} from 'fflate';
2
+ import {XMLParser} from 'fast-xml-parser';
3
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false,removeNSPrefix:true,htmlEntities:{amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}});
4
+ const decoder=new TextDecoder('utf-8',{fatal:true}),encoder=new TextEncoder();
5
+ const array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
6
+ const xml=(entries,path)=>entries[path]?parser.parse(decoder.decode(entries[path])):undefined;
7
+ const find=(value,key)=>!value||typeof value!=='object'?[]:Array.isArray(value)?value.flatMap(item=>find(item,key)):Object.entries(value).flatMap(([name,child])=>name===key?array(child):find(child,key));
8
+ const text=value=>typeof value==='string'?value:value&&typeof value==='object'?String(value['#text']??''):value===undefined?'':String(value);
9
+ const richText=value=>value?.t!==undefined?text(value.t):array(value?.r).map(run=>text(run.t)).join('');
10
+ const escape=value=>value.replace(/[&<>"'\r\n\t]/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'}[char]));
11
+ function relationship(entries,source,id){
12
+ if(typeof id!=='string'||!id)return undefined;
13
+ const slash=source.lastIndexOf('/'),part=`${source.slice(0,slash+1)}_rels/${source.slice(slash+1)}.rels`;
14
+ const rel=array(xml(entries,part)?.Relationships?.Relationship).find(rel=>rel.Id===id);
15
+ if(!rel||(rel.TargetMode!==undefined&&rel.TargetMode!=='Internal')||typeof rel.Target!=='string'||/^[a-z]+:/i.test(rel.Target)||/[\\?#]/.test(rel.Target))return undefined;
16
+ const pieces=[];
17
+ for(const component of (rel.Target.startsWith('/')?rel.Target.slice(1):source.slice(0,slash+1)+rel.Target).split('/')){
18
+ if(component==='..'){if(!pieces.length)return undefined;pieces.pop();}else if(component&&component!=='.')pieces.push(component);
19
+ }
20
+ return {path:pieces.join('/'),type:rel.Type};
21
+ }
22
+ function workbookContext(entries,chartPart){
23
+ const chart=xml(entries,chartPart)?.chartSpace;
24
+ const ref=relationship(entries,chartPart,chart?.externalData?.id);
25
+ if(!ref?.type?.endsWith('/package')||!entries[ref.path])return undefined;
26
+ // Read only bounded spreadsheet metadata. Never follow external workbook links.
27
+ let size=0;
28
+ const workbook=unzipSync(entries[ref.path],{filter:file=>{
29
+ if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(file.name))return false;
30
+ size+=file.originalSize;if(size>16*1024*1024)throw Error('Chart workbook metadata exceeds the limit.');return true;
31
+ }});
32
+ const category=find(chart,'cat')[0],formula=category?.strRef?.f??category?.multiLvlStrRef?.f??category?.numRef?.f;
33
+ // Infer a heading only for one contiguous vertical category range with a row
34
+ // above it. Other chart/workbook arrangements remain explicitly unrecovered.
35
+ const range=/^(?:'((?:[^']|'')+)'|([^'!]+))!\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)$/i.exec(text(formula));
36
+ if(!range||range[3].toUpperCase()!==range[5].toUpperCase()||Number(range[4])<2||Number(range[6])<Number(range[4])||Number(range[6])>1048576)return undefined;
37
+ const column=range[3].toUpperCase();
38
+ if(column.length>3||[...column].reduce((value,letter)=>value*26+letter.charCodeAt(0)-64,0)>16384)return undefined;
39
+ const name=(range[1]?.replaceAll("''","'")??range[2]);
40
+ const sheet=array(xml(workbook,'xl/workbook.xml')?.workbook?.sheets?.sheet).find(sheet=>sheet.name===name);
41
+ const sheetRef=relationship(workbook,'xl/workbook.xml',sheet?.id);
42
+ if(!sheetRef?.type?.endsWith('/worksheet')||!workbook[sheetRef.path])return undefined;
43
+ const cellRef=`${column}${Number(range[4])-1}`;
44
+ const cell=find(xml(workbook,sheetRef.path)?.worksheet,'c').find(cell=>cell.r===cellRef);
45
+ return {workbookPart:ref.path,workbook,sheetPart:sheetRef.path,cellRef,cell};
46
+ }
47
+ export function readChartCategoryHeading(entries,chartPart){
48
+ try{
49
+ const context=workbookContext(entries,chartPart),cell=context?.cell;if(!cell||cell.f!==undefined)return undefined;
50
+ if(cell.t==='inlineStr')return richText(cell.is);
51
+ if(cell.t==='s'){
52
+ const value=text(cell.v);if(!/^\d+$/.test(value))return undefined;
53
+ const item=array(xml(context.workbook,'xl/sharedStrings.xml')?.sst?.si)[Number(value)];
54
+ return item===undefined?undefined:richText(item);
55
+ }
56
+ // Error, boolean, date and formula results are not literal text headings.
57
+ if(cell.t!==undefined&&!['str','n'].includes(cell.t))return undefined;
58
+ return cell.v===undefined?undefined:text(cell.v);
59
+ }catch{return undefined;}
60
+ }
61
+ export function writeChartCategoryHeading(entries,chartPart,heading){
62
+ const context=workbookContext(entries,chartPart);
63
+ if(!context?.cell||typeof heading!=='string')throw Error('Generated chart has no editable category heading cell.');
64
+ for(const character of heading){const point=character.codePointAt(0);if(![9,10,13].includes(point)&&(point<32||point>=0xD800&&point<=0xDFFF||point===0xFFFE||point===0xFFFF))throw Error('Chart category heading contains an XML-invalid character.');}
65
+ const workbook=unzipSync(entries[context.workbookPart]);
66
+ const source=decoder.decode(workbook[context.sheetPart]),pattern=new RegExp(`<c\\b(?=[^>]*\\br="${context.cellRef}")[^>]*(?:\\/>|>[\\s\\S]*?<\\/c>)`);
67
+ if(!pattern.test(source))throw Error('Generated category heading cell is missing.');
68
+ workbook[context.sheetPart]=encoder.encode(source.replace(pattern,()=>`<c r="${context.cellRef}" t="inlineStr"><is><t xml:space="preserve">${escape(heading)}</t></is></c>`));
69
+ // Update only table metadata related to this generated sheet and starting at
70
+ // this header. Other embedded sheets/tables are never rewritten by inference.
71
+ const slash=context.sheetPart.lastIndexOf('/');
72
+ const relationships=xml(workbook,`${context.sheetPart.slice(0,slash+1)}_rels/${context.sheetPart.slice(slash+1)}.rels`);
73
+ for(const rel of array(relationships?.Relationships?.Relationship)){
74
+ const ref=relationship(workbook,context.sheetPart,rel.Id);
75
+ if(!ref?.type?.endsWith('/table')||!workbook[ref.path])continue;
76
+ const table=decoder.decode(workbook[ref.path]);
77
+ if(xml(workbook,ref.path)?.table?.ref?.split(':')[0]!==context.cellRef)continue;
78
+ workbook[ref.path]=encoder.encode(table.replace(/(<tableColumn\b[^>]*\bid="1"[^>]*\bname=")[^"]*(")/,(_match,prefix,suffix)=>`${prefix}${escape(heading)}${suffix}`));
79
+ }
80
+ entries[context.workbookPart]=zipSync(workbook);
81
+ }
@@ -21,6 +21,12 @@ export function codeManifest(value, layout, group) {
21
21
  }
22
22
 
23
23
  export function attachCodeTags(entries, records) {
24
+ return attachTextTags(entries,records,TAG,'opfCode','code');
25
+ }
26
+
27
+ export {unhex as decodeTextTag};
28
+
29
+ export function attachTextTags(entries, records, tagName, prefix, kind) {
24
30
  if (!records.size) return;
25
31
  let count = 0;
26
32
  const seen = new Set(), types = [];
@@ -31,23 +37,23 @@ export function attachCodeTags(entries, records) {
31
37
  const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
32
38
  const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
33
39
  if (!records.has(name)) return shape;
34
- if (seen.has(name)) throw new Error('Duplicate generated code shape.');
40
+ if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
35
41
  seen.add(name);
36
- const part = `ppt/tags/opfCode${++count}.xml`;
37
- let id = `rIdOpfCode${count}`;
42
+ const part = `ppt/tags/${prefix}${++count}.xml`;
43
+ let id = `rId${prefix[0].toUpperCase()+prefix.slice(1)}${count}`;
38
44
  while (ids.has(id)) id += '_';
39
45
  ids.add(id);
40
- entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${hex(records.get(name))}"/></p:tagLst>`);
46
+ entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${tagName}" val="${hex(records.get(name))}"/></p:tagLst>`);
41
47
  types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
42
- rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/opfCode${count}.xml"/></Relationships>`);
48
+ rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
43
49
  shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
44
- if (!shape.includes('</p:nvPr>')) throw new Error('Generated code shape has no native application properties.');
50
+ if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
45
51
  return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
46
52
  });
47
53
  entries[path] = enc.encode(xml);
48
54
  entries[relPath] = enc.encode(rels);
49
55
  }
50
- if (seen.size !== records.size) throw new Error('Missing generated code shapes.');
56
+ if (seen.size !== records.size) throw new Error(`Missing generated ${kind} shapes.`);
51
57
  entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>',types.join('')+'</Types>'));
52
58
  }
53
59
 
@@ -120,10 +126,13 @@ export function importCodeGroups(shapes, paragraphs, relationships, entries, rep
120
126
  const rel = relationships.get(link['r:id']);
121
127
  if (rel?.type !== REL || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
122
128
  let tags;
123
- try { tags = array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag=>tag.name?.toUpperCase() === TAG); }
129
+ try {
130
+ const all=array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']);
131
+ tagCounts.set(shape,(tagCounts.get(shape)??0)+all.filter(tag=>/^OPF_/i.test(tag.name)).length);
132
+ tags=all.filter(tag=>tag.name?.toUpperCase()===TAG);
133
+ }
124
134
  catch { report({code:'invalid-code-provenance',message:'Unreadable code tags; visible native shapes were retained.'}); continue; }
125
135
  for (const tag of tags) {
126
- tagCounts.set(shape,(tagCounts.get(shape) ?? 0)+1);
127
136
  try {
128
137
  const data = unhex(tag.val);
129
138
  if (data.v !== 1 || typeof data.group !== 'string' || !/^\d+$/.test(data.group)) throw new Error('Invalid code identity.');
@@ -0,0 +1,44 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
+ import {sourceLineParagraphs} from './text-provenance.js';
4
+ const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
5
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
6
+ const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
7
+ export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfHeading','heading');
8
+
9
+ // Tags carry only roles and line ordering. Current native text always wins;
10
+ // neither original source text nor old geometry can be restored from these tags.
11
+ export function importHeadingGroups(shapes,paragraphs,relationships,entries,report) {
12
+ const groups=new Map(),consumed=new Set(),items=[];
13
+ for(const [index,shape] of shapes.entries()) {
14
+ const tags=[];let unreadable=false;
15
+ for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
16
+ const rel=relationships.get(link['r:id']);
17
+ if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
18
+ try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
19
+ }
20
+ const candidates=tags.filter(tag=>tag.name?.toUpperCase()===TAG);
21
+ if(!candidates.length)continue;
22
+ for(const tag of candidates)try {
23
+ const data=decodeTextTag(tag.val);
24
+ if(data?.v!==1||!['title','subtitle','tag'].includes(data.field)||typeof data.group!=='string'||!/^slides\.\d+\.(title|subtitle|tag)$/.test(data.group)||!data.group.endsWith('.'+data.field))throw Error('Invalid heading identity.');
25
+ const group=groups.get(data.field)??[];
26
+ group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.field,group);
27
+ }catch{report({code:'invalid-heading-provenance',message:'Invalid heading tags; ordinary import retains visible native text.'});}
28
+ }
29
+ for(const group of groups.values())try {
30
+ const first=group[0].data,count=first.count;
31
+ if(!Number.isSafeInteger(count)||count<1||count>512||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated heading lines.');
32
+ const ordered=Array(count);
33
+ for(const item of group) {
34
+ const data=item.data;
35
+ if(item.ambiguous||data.group!==first.group||data.count!==count||!Number.isSafeInteger(data.line)||data.line<0||data.line>=count||ordered[data.line])throw Error('Ambiguous heading line.');
36
+ ordered[data.line]=item;
37
+ }
38
+ const restored=sourceLineParagraphs(ordered,paragraphs,{legacy:true});
39
+ for(const item of ordered)consumed.add(item.shape);
40
+ items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored});
41
+ report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Formatting and geometry are not reconstructed.'});
42
+ }catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
43
+ return {consumed,items};
44
+ }
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface ToPptxOptions {
37
37
  /** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
38
38
  imageFormat?: "compatible" | "preserve";
39
39
  textMeasurement?: TextMeasurement;
40
+ /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
41
+ textRasterPadding?: number;
40
42
  onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
41
43
  baseDir?: string;
42
44
  compressionLevel?: number;
package/dist/index.js CHANGED
@@ -1,11 +1,17 @@
1
1
  import {importTableFrames} from './table-import.js';
2
+ import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
2
3
  import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
4
+ import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
5
+ import {attachCardTags,importCardFrames} from './card-provenance.js';
6
+ import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
+ import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
+ import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
3
9
  import {importImageOrientation} from './image-import.js';
4
10
  import {nativeBackgroundFill} from './background.js';
5
11
  import {importBackground} from './background-import.js';
6
12
  import { webpToPng } from '#image-fallback';
7
13
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
8
- import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
14
+ import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
9
15
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
10
16
  import { unzipSync, zipSync } from "fflate";
11
17
  import { XMLParser } from "fast-xml-parser";
@@ -171,7 +177,13 @@ export async function toPptx(input, options = {}) {
171
177
  context.tableCells = new Map();
172
178
  context.imagePlacements = new Map();
173
179
  context.backgroundFills = new Map();
180
+ context.cardTags = new Map();
181
+ context.headingTags = new Map();
182
+ context.plainTextTags = new Map();
183
+ context.timelineTags = new Map();
174
184
  context.codeTags = new Map();
185
+ context.metricTags = new Map();
186
+ context.chartHeadings = new Map();
175
187
  context.imageFormat = options.imageFormat ?? "compatible";
176
188
  const pptx = new PptxGenJS();
177
189
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
@@ -384,9 +396,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
384
396
 
385
397
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
386
398
  .sort(comparePositionedItems);
387
- const titleItem = takeTitleItem(items, dimensions);
399
+ // Complete OPF heading roles are authoritative. Nearby body lines must not
400
+ // fill an absent role by geometry; explicit native placeholders still apply.
401
+ const inferHeadings = !items.some(item => item.heading||item.sourceText);
402
+ const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
403
+ const tagItem=heading('tag');
404
+ if(tagItem)slide.tag=tagItem.text;
405
+ const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
388
406
  if (titleItem) slide.title = titleItem.text;
389
- const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
407
+ const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
390
408
  if (subtitleItem) slide.subtitle = subtitleItem.text;
391
409
 
392
410
  const blocks = mergeAdjacentBulletShapes(items)
@@ -407,9 +425,28 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
407
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.'});
408
426
  const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
409
427
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
428
+ const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
429
+ const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
430
+ const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
431
+ const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
432
+ const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
433
+ for(const group of [...headings.items,...plainText.items]) {
434
+ const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
435
+ if(item){
436
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
437
+ if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
438
+ items.push({...item,heading:group.field,sourceText:!group.field});
439
+ }
440
+ }
441
+ for(const group of timelines.items){
442
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
443
+ let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
444
+ items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
445
+ }
410
446
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
447
+ for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
411
448
  for (const [index,shape] of shapes.entries()) {
412
- if (code.consumed.has(shape)) continue;
449
+ 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;
413
450
  const item = importShape(shape, dimensions, paragraphs[index]);
414
451
  if (item) items.push(item);
415
452
  }
@@ -433,13 +470,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
433
470
  return items;
434
471
  }
435
472
 
436
- function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"])) {
473
+ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"]), allowEmptyText = false) {
437
474
  const text = paragraphs.map((paragraph) => paragraph.text).join("\n");
438
475
  const placeholder = shapePlaceholderType(shape);
439
476
  const bounds = shapeBounds(shape["p:spPr"]?.["a:xfrm"]);
440
477
  const name = scalarText(shape["p:nvSpPr"]?.["p:cNvPr"]?.name).trim();
441
478
 
442
- if (text) {
479
+ if (text || allowEmptyText) {
443
480
  return {
444
481
  kind: "text",
445
482
  text,
@@ -572,13 +609,14 @@ function shapeBounds(xfrm) {
572
609
  return { x, y, w, h };
573
610
  }
574
611
 
575
- function takeTitleItem(items, dimensions) {
612
+ function takeTitleItem(items, dimensions, inferHeadings) {
576
613
  const explicitIndex = items.findIndex((item) => ["title", "ctrTitle"].includes(item.placeholder));
577
614
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
615
+ if (!inferHeadings) return null;
578
616
 
579
617
  const titleLimit = dimensions.heightInches * 0.28;
580
618
  const candidateIndex = items.findIndex((item) => {
581
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
619
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
582
620
  const y = item.bounds?.y ?? 0;
583
621
  return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
584
622
  });
@@ -586,15 +624,15 @@ function takeTitleItem(items, dimensions) {
586
624
  return null;
587
625
  }
588
626
 
589
- function takeSubtitleItem(items, titleItem, dimensions) {
627
+ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
590
628
  const explicitIndex = items.findIndex((item) => item.placeholder === "subTitle");
591
629
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
592
- if (!titleItem) return null;
630
+ if (!inferHeadings || !titleItem) return null;
593
631
 
594
632
  const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
595
633
  const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
596
634
  const candidateIndex = items.findIndex((item) => {
597
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
635
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
598
636
  const y = item.bounds?.y ?? 0;
599
637
  const h = item.bounds?.h ?? 0;
600
638
  return y >= titleBottom - 0.05
@@ -674,7 +712,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
674
712
  return {
675
713
  type: chartNode.type,
676
714
  data: {
677
- columns: ["Category", ...names],
715
+ columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
678
716
  rows
679
717
  }
680
718
  };
@@ -888,31 +926,47 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
888
926
  const { widthInches, heightInches } = slideContext.dimensions;
889
927
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
890
928
  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` });
891
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
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 });
892
930
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
893
931
  for (const item of geometry.items) {
894
932
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
895
- if (["title", "subtitle", "tag"].includes(item.field)) {
933
+ if (item.frameBox) {
934
+ const frame=item.frameBox;
935
+ context.cardTags.set(`OPF card ${item.path}`,item.path);
936
+ const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
937
+ const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
938
+ slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
939
+ rectRadius:8*Math.min(widthInches,heightInches)/720,
940
+ fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
941
+ }
942
+ if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
943
+ addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
944
+ } else if (["title", "subtitle", "tag"].includes(item.field)) {
945
+ // Estimated-font headings use one native text box, which still needs a
946
+ // role tag. Role recovery must not depend on outline measurement support.
947
+ const objectName = `OPF heading ${item.path} line 0`;
948
+ context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
896
949
  slide.addText(item.text.lines.join("\n"), {
897
950
  ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
898
- fontFace: item.textStyle.fontFamily,
899
- bold: item.textStyle.fontWeight >= 600,
900
- italic: item.textStyle.italic,
951
+ ...nativeFontOptions(item.textStyle),
901
952
  color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
953
+ objectName,
902
954
  breakLine: false
903
955
  });
904
956
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
905
957
  addMeasuredList(slide,item.text,slideContext);
906
958
  } else if (item.field === "text" && item.text?.richLines) {
907
- const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
908
- for(const line of item.text.richLines){
909
- const runs=line.fragments.map(fragment=>({text:fragment.text,options:{fontFace:fragment.style.fontFamily,fontSize:fragment.fontSize*.75,bold:fragment.style.fontWeight>=600,italic:fragment.style.italic,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}}));
910
- if(runs.length)slide.addText(runs,{...textBoxOptions({...region,y:region.y+line.y/96,h:line.height/96},slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
959
+ const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
960
+ 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}}));
962
+ const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
963
+ 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
+ if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
911
965
  }
912
966
  } else if (item.field === "text" && typeof item.value === "string") {
913
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),fontFace:item.textStyle.fontFamily,bold:item.textStyle.fontWeight>=600,italic:item.textStyle.italic});
967
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
914
968
  } else {
915
- await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout);
969
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
916
970
  }
917
971
  }
918
972
 
@@ -933,7 +987,7 @@ function fieldToType(field) {
933
987
  return field === "items" || field === "bullets" ? "list" : field;
934
988
  }
935
989
 
936
- async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout) {
990
+ async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
937
991
  const kind = inferPayloadKind(payload);
938
992
  switch (kind) {
939
993
  case "text":
@@ -958,13 +1012,13 @@ async function addPayload(slide, presentation, payload, region, path, context, o
958
1012
  addCodePayload(slide, payload.code, codeLayout, region, context, path);
959
1013
  break;
960
1014
  case "metric":
961
- addMetricPayload(slide, payload.metric, region, context, options, path);
1015
+ addMetricPayload(slide, payload.metric,metricLayout,context,path);
962
1016
  break;
963
1017
  case "quote":
964
1018
  addQuotePayload(slide, quoteLayout, context, options, path);
965
1019
  break;
966
1020
  case "timeline":
967
- addTimelinePayload(slide, payload.timeline, region, context, options, path);
1021
+ addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
968
1022
  break;
969
1023
  default:
970
1024
  addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
@@ -994,7 +1048,7 @@ function addTextPayload(slide, value, region, context) {
994
1048
  }
995
1049
 
996
1050
  function richLineRuns(line,color) {
997
- return line.fragments.map(fragment=>({text:fragment.text,options:{fontFace:fragment.style.fontFamily,fontSize:fragment.fontSize*.75,bold:fragment.style.fontWeight>=600,italic:fragment.style.italic,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}}));
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}}));
998
1052
  }
999
1053
  function addMeasuredList(slide,fit,context) {
1000
1054
  for(const entry of fit.listEntries){
@@ -1010,7 +1064,7 @@ function addMeasuredList(slide,fit,context) {
1010
1064
  // Keep paragraph intent identical across runs. ZIP normalization below
1011
1065
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
1012
1066
  for(const run of runs)Object.assign(run.options,paragraph);
1013
- slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.fontFamily,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1067
+ slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1014
1068
  });
1015
1069
  };
1016
1070
  addLines(entry.text,entry.textBox,context.colors.text,true);
@@ -1083,21 +1137,36 @@ function addChartPayload(slide, chart, region, context) {
1083
1137
  return;
1084
1138
  }
1085
1139
 
1140
+ // Keep the resolved palette surface (including alpha) explicit in native
1141
+ // chart/plot areas, so inherited labels are assessed against their own panel.
1142
+ const panelFill = context.colorScheme.surface ?? context.colorScheme[isDarkHex(context.colors.background) ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
1143
+ const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
1144
+ const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
1145
+ const fill = {color:normalizeHex(panelFill),transparency};
1146
+ const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1147
+ const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1148
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
1086
1149
  slide.addChart(chartData.type, chartData.series, {
1150
+ objectName,
1087
1151
  x: region.x,
1088
1152
  y: region.y,
1089
1153
  w: region.w,
1090
1154
  h: region.h,
1091
- showLegend: chartData.series.length > 1,
1155
+ showLegend: circular || chartData.series.length > 1,
1092
1156
  showTitle: false,
1093
- chartColors: CHART_COLORS,
1157
+ chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
1158
+ chartArea: {fill:{...fill},roundedCorners:false},
1159
+ // Paint alpha once in the chart area, rather than stacking two alpha fills.
1160
+ plotArea: {fill:{color:null}},
1094
1161
  catAxisLabelFontFace: context.fonts.body,
1095
1162
  catAxisLabelFontSize: 9,
1096
- catAxisLabelColor: context.colors.text,
1163
+ catAxisLabelColor: labelColor,
1097
1164
  valAxisLabelFontFace: context.fonts.body,
1098
1165
  valAxisLabelFontSize: 9,
1099
- valAxisLabelColor: context.colors.text,
1100
- legendColor: context.colors.text,
1166
+ valAxisLabelColor: labelColor,
1167
+ legendColor: labelColor,
1168
+ legendFontFace: context.fonts.body,
1169
+ dataLabelColor: labelColor,
1101
1170
  showValue: false,
1102
1171
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1103
1172
  barDir: chartData.barDir,
@@ -1121,8 +1190,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1121
1190
  const rows = layout.rows.map(row => row.cells.map(cell => {
1122
1191
  const {header,rich,fit} = cell;
1123
1192
  const cellStyle = cell.style ?? {};
1124
- const baseColor = (cellStyle.color ?? (header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1125
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(/^#/, '');
1126
1195
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1127
1196
  const text = stringifyText(cell.value), style = cell.textStyle;
1128
1197
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1133,8 +1202,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1133
1202
  const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
1134
1203
  const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1135
1204
  const runOptions = {
1136
- fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1137
- bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
1205
+ ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1138
1206
  underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1139
1207
  color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1140
1208
  hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
@@ -1150,13 +1218,12 @@ function addTablePayload(slide, table, region, context, options, path) {
1150
1218
  // soft wraps into the text would change a later import or copy operation.
1151
1219
  text: rich ? runs : text,
1152
1220
  options: {
1153
- fontFace: style.fontFamily,
1221
+ ...nativeFontOptions(style),
1154
1222
  fontSize: fit.fontSize * 0.75,
1155
1223
  // PptxGenJS fills falsy run options from cell defaults. Rich runs
1156
1224
  // carry their resolved weight, so a bold header default must not turn
1157
1225
  // an explicit bold:false run back on.
1158
- bold: rich ? false : style.fontWeight >= 600,
1159
- italic: rich ? false : style.italic,
1226
+ ...(rich ? {bold:false,italic:false} : {}),
1160
1227
  lineSpacing: fit.lineHeight * 0.75,
1161
1228
  paraSpaceAfter: 0,
1162
1229
  align: cellStyle.align ?? context.contentAlignment,
@@ -1199,6 +1266,8 @@ function pixelBox(region) {
1199
1266
  // Match the published renderer's payload geometry and shared core text fitting.
1200
1267
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
1201
1268
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
1269
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
1270
+ if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path:config.path});
1202
1271
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1203
1272
  const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
1204
1273
  const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
@@ -1208,11 +1277,20 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1208
1277
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1209
1278
  }
1210
1279
  for (const [index, line] of fit.lines.entries()) {
1211
- if (!line) continue;
1280
+ if (!line&&!config.heading&&!config.sourceText&&!config.timeline) continue;
1281
+ const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1282
+ 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;
1284
+ if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1285
+ 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}:{})});
1286
+ else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1287
+ 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};
1212
1288
  slide.addText(line, {
1213
- ...textBoxOptions({x: box.x / 96, y: (box.y + index * fit.lineHeight) / 96, w: box.width / 96, h: fit.lineHeight / 96}, context, fit.fontSize * .75),
1214
- fontFace: style.fontFamily, bold: style.fontWeight >= 600, italic: style.italic,
1215
- color: normalizeHex(config.color ?? context.colors.text), align: config.align ?? context.contentAlignment ?? 'left',
1289
+ ...textBoxOptions(area, context, fit.fontSize * .75),
1290
+ ...nativeFontOptions(style),
1291
+ color: normalizeHex(config.color ?? context.colors.text), align: alignment,
1292
+ tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
1293
+ objectName,
1216
1294
  fit: 'none', wrap: false, lineSpacingMultiple: 1,
1217
1295
  });
1218
1296
  }
@@ -1238,7 +1316,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
1238
1316
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
1239
1317
  slide.addText(part.text.slice(line.start,line.end),{
1240
1318
  ...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
1241
- fontFace:part.style.fontFamily,bold:part.style.fontWeight>=600,italic:part.style.italic,
1319
+ ...nativeFontOptions(part.style),
1242
1320
  color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
1243
1321
  tabStops:tabStops.length?tabStops:undefined,objectName,
1244
1322
  });
@@ -1246,10 +1324,31 @@ function addCodePayload(slide, value, layout, region, context, path) {
1246
1324
  }
1247
1325
  }
1248
1326
 
1249
- function addMetricPayload(slide, value, region, context, options, path) {
1250
- const metric = isPlainObject(value) ? value : {value}, box = pixelBox(region);
1251
- addMeasuredPayloadText(slide, metric.value, {...box, height: box.height * .45}, context, options, {path: path + '.value', fontSize: Math.min(76, box.height * .28), fontFamily: context.fonts.heading, fontWeight: 800, color: context.colors.accent});
1252
- addMeasuredPayloadText(slide, [metric.label, metric.description, metric.delta].filter(Boolean).join('\n'), {x: box.x, y: box.y + box.height * .45, width: box.width, height: box.height * .55}, context, options, {path, fontSize: 23, fontWeight: 500});
1327
+ function addMetricPayload(slide,value,layout,context,path) {
1328
+ if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
1329
+ const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
1330
+ for (const [partIndex,part] of layout.parts.entries()) {
1331
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1332
+ if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
1333
+ if (!part.visible) continue;
1334
+ if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
1335
+ for (const [index,line] of part.fit.sourceLines.entries()) {
1336
+ const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
1337
+ context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
1338
+ const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
1339
+ // Anchor the native paragraph to the same accepted left/center/right point.
1340
+ // Left-aligning at a measured glyph origin loses the intended edge when the
1341
+ // native shaper has a slightly different advance. No re-fitting is needed.
1342
+ const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
1343
+ const anchor=origin.x+line.width*factor;
1344
+ slide.addText(part.text.slice(line.start,line.end),{
1345
+ ...textBoxOptions({x:(anchor-part.box.width*factor)/96,y:(origin.baseline-part.fit.fontSize)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
1346
+ ...nativeFontOptions(part.style),
1347
+ color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1348
+ tabStops:tabStops.length?tabStops:undefined,objectName,
1349
+ });
1350
+ }
1351
+ }
1253
1352
  }
1254
1353
 
1255
1354
  function addQuotePayload(slide, layout, context, options, path) {
@@ -1263,20 +1362,23 @@ function addQuotePayload(slide, layout, context, options, path) {
1263
1362
  }
1264
1363
  }
1265
1364
 
1266
- function addTimelinePayload(slide, value, region, context, options, path) {
1267
- const timeline = Array.isArray(value) ? {events: value} : value;
1268
- const events = Array.isArray(timeline?.events) ? timeline.events : [];
1269
- if (!events.length) return;
1270
- const box = pixelBox(region), labelWidth = box.width / Math.max(2, events.length);
1271
- const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
1272
- const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
1273
- const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
1274
- const y = box.y + box.height * .46, radius = Math.min(9, labelWidth / 5, box.height * .04);
1275
- slide.addShape('line', {x: start / 96, y: y / 96, w: (end - start) / 96, h: 0, line: {color: context.colors.border, pt: 3 * .75}});
1276
- for (const [index, event] of events.entries()) {
1277
- const x = start + index * gap;
1278
- slide.addShape('ellipse', {x: (x - radius) / 96, y: (y - radius) / 96, w: radius * 2 / 96, h: radius * 2 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}});
1279
- addMeasuredPayloadText(slide, [event.when, event.what, event.description].filter(Boolean).join('\n'), {x: x - labelWidth / 2, y: index % 2 === 0 ? box.y : y + 24, width: labelWidth, height: box.height * .38}, context, options, {path: `${path}.events.${index}`, fontSize: 16, fontWeight: 500, align: 'center'});
1365
+ function addTimelinePayload(slide, value, layout, context, options, path) {
1366
+ if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
1367
+ // Validate optional field fits before the provenance manifest dereferences
1368
+ // source lines or any drawing/tag mutation is performed.
1369
+ for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
1370
+ const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
1371
+ const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
1372
+ const {x1,y1,x2,y2}=layout.connector;
1373
+ slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,pt:3*scale*.75}});
1374
+ context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
1375
+ for(const marker of layout.markers){
1376
+ const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
1377
+ slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
1378
+ context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
1379
+ }
1380
+ for(const [index,part]of layout.parts.entries()){
1381
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor}});
1280
1382
  }
1281
1383
  }
1282
1384
 
@@ -1304,6 +1406,16 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1304
1406
  });
1305
1407
  }
1306
1408
 
1409
+ function nativeFontOptions(style) {
1410
+ const face=style.fontFace;
1411
+ if(face!==undefined) {
1412
+ if(!face || typeof face.family!=='string' || !face.family.trim() || typeof face.bold!=='boolean' || typeof face.italic!=='boolean')
1413
+ throw new OPFPptxError('invalid-font-selection','The font provider must supply a family and explicit bold/italic style-link flags.',{path:style.path});
1414
+ return {fontFace:face.family,bold:face.bold,italic:face.italic};
1415
+ }
1416
+ return {fontFace:style.fontFamily,bold:style.fontWeight>=600,italic:style.italic};
1417
+ }
1418
+
1307
1419
  function textBoxOptions(region, context, fontSize) {
1308
1420
  return {
1309
1421
  x: region.x,
@@ -1575,6 +1687,26 @@ async function normalizePptxZip(raw, context) {
1575
1687
  }
1576
1688
 
1577
1689
  attachCodeTags(entries, context.codeTags);
1690
+ attachMetricTags(entries,context.metricTags);
1691
+ attachCardTags(entries,context.cardTags);
1692
+ attachHeadingTags(entries,context.headingTags);
1693
+ attachPlainTextTags(entries,context.plainTextTags);
1694
+ attachTimelineTags(entries,context.timelineTags);
1695
+ for(const [part,bytes]of Object.entries(entries)){
1696
+ if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
1697
+ const relationships=parseRelationships(entries,part);
1698
+ for(const [frame]of decodeText(bytes).matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)){
1699
+ const name=frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
1700
+ if(!context.chartHeadings.has(name))continue;
1701
+ const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
1702
+ if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
1703
+ const {heading,labelColor}=context.chartHeadings.get(name);
1704
+ if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
1705
+ // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
1706
+ // Normalize only our generated chart text styles; point/series fills stay intact.
1707
+ entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
1708
+ }
1709
+ }
1578
1710
  const imageSources = new Map();
1579
1711
  for (const [part, bytes] of Object.entries(entries)) {
1580
1712
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
@@ -1650,6 +1782,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1650
1782
  if (isXmlPart(path)) {
1651
1783
  let xml=decodeText(bytes);
1652
1784
  if (path === '[Content_Types].xml') {
1785
+ // PptxGenJS 4.0.1 emits one slide-master override per slide even
1786
+ // though it creates only the actual master parts. Omit phantom master
1787
+ // declarations without changing any existing part or relationship.
1788
+ xml = xml.replace(/<Override PartName="\/(ppt\/slideMasters\/slideMaster\d+\.xml)"[^>]*\/>/g,
1789
+ (override, part) => Object.hasOwn(entries, part) ? override : '');
1653
1790
  // Explicit per-part types also correct PptxGenJS's image/jpg default.
1654
1791
  const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
1655
1792
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
@@ -0,0 +1,94 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {validatePresentation} from '@openpresentation/opf';
3
+ import {attachTextTags,decodeTextTag} from './code-provenance.js';
4
+
5
+ const TAG='OPF_METRIC_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
6
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
7
+ const decoder=new TextDecoder('utf-8',{fatal:true});
8
+ const array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
9
+ const scalar=value=>typeof value==='string'||typeof value==='number';
10
+ const sourceFor=(value,role)=>scalar(value)?value:value[role];
11
+
12
+ // The first value line carries the complete manifest: no invisible panel is introduced.
13
+ export function metricManifest(value,layout,group) {
14
+ return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
15
+ lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
16
+ }
17
+ export function attachMetricTags(entries,records) {return attachTextTags(entries,records,TAG,'opfMetric','metric');}
18
+
19
+ function validateManifest(manifest) {
20
+ if (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
21
+ const roles=scalar(manifest.value)?['value']:['value','unit','label','description','delta','trend'].filter(role=>manifest.value[role]!==undefined);
22
+ if (!Array.isArray(manifest.parts)||manifest.parts.length!==roles.length||manifest.part!==0||manifest.line!==0) throw new Error('Invalid metric parts.');
23
+ for (const [index,part] of manifest.parts.entries()) {
24
+ const source=String(sourceFor(manifest.value,roles[index])),visible=roles[index]==='value'||source.length>0;
25
+ if (part.role!==roles[index]||part.visible!==visible||!Array.isArray(part.lines)||visible&&!part.lines.length||!visible&&part.lines.length) throw new Error('Invalid source part.');
26
+ let cursor=0;
27
+ for (const [i,line] of part.lines.entries()) {
28
+ if (![line.start,line.end,line.nextStart].every(Number.isSafeInteger)||line.start!==cursor||line.end<line.start||line.nextStart<line.end||line.nextStart>source.length) throw new Error('Invalid source range.');
29
+ const separator=source.slice(line.end,line.nextStart),last=i===part.lines.length-1;
30
+ if (/\r|\n/.test(source.slice(line.start,line.end))||(line.boundary==='hard'?!/^(?:\r\n|\r|\n)$/.test(separator)||last:separator!==''||line.boundary!==(last?'end':'soft'))) throw new Error('Invalid source boundary.');
31
+ cursor=line.nextStart;
32
+ }
33
+ if (cursor!==source.length) throw new Error('Incomplete source range.');
34
+ }
35
+ }
36
+
37
+ export function importMetricGroups(shapes,paragraphs,relationships,entries,report) {
38
+ const groups=new Map(),tagCounts=new Map(),consumed=new Set(),items=[];
39
+ for (const [index,shape] of shapes.entries()) {
40
+ for (const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
41
+ const rel=relationships.get(link['r:id']);
42
+ if (rel?.type!==REL||rel.targetMode==='External'||!rel.path||!entries[rel.path]) continue;
43
+ let tags;
44
+ try {
45
+ const all=array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']);
46
+ tagCounts.set(shape,(tagCounts.get(shape)??0)+all.filter(tag=>/^OPF_/i.test(tag.name)).length);
47
+ tags=all.filter(tag=>tag.name?.toUpperCase()===TAG);
48
+ } catch {report({code:'invalid-metric-provenance',message:'Unreadable metric tags; visible native shapes were retained.'});continue;}
49
+ for (const tag of tags) try {
50
+ const data=decodeTextTag(tag.val);
51
+ if (data.v!==1||typeof data.group!=='string'||!/^\d+$/.test(data.group)) throw new Error('Invalid metric identity.');
52
+ const group=groups.get(data.group)??[];
53
+ group.push({data,shape,text:(paragraphs[index]??[]).map(p=>p.text).join('\n')});groups.set(data.group,group);
54
+ } catch {report({code:'invalid-metric-provenance',message:'Invalid metric tags; visible native shapes were retained.'});}
55
+ }
56
+ }
57
+ for (const group of groups.values()) try {
58
+ if (group.some(item=>tagCounts.get(item.shape)!==1)) throw new Error('Multiple source identities on one shape.');
59
+ const anchors=group.filter(item=>item.data.role==='anchor');
60
+ if (anchors.length!==1) throw new Error('Missing or duplicated metric anchor.');
61
+ const anchor=anchors[0],manifest=anchor.data;validateManifest(manifest);
62
+ const expected=manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
63
+ if (group.length!==expected||new Set(group.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
64
+ const nativeLines=new Map();
65
+ for (const item of group) {
66
+ const {role,part,line}=item.data,key=`${part}:${line}`;
67
+ if ((item!==anchor&&role!=='line')||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||!manifest.parts[part]?.lines[line]||nativeLines.has(key)) throw new Error('Ambiguous metric source line.');
68
+ nativeLines.set(key,item.text);
69
+ }
70
+ const value=scalar(manifest.value)?{value:manifest.value}:{...manifest.value},typeChanges=[];
71
+ for (const [partIndex,part] of manifest.parts.entries()) {
72
+ if (!part.visible) continue;
73
+ const original=sourceFor(manifest.value,part.role),source=String(original),newline=source.match(/\r\n|\r|\n/)?.[0]??'\n';
74
+ let rebuilt='';
75
+ for (const [lineIndex,line] of part.lines.entries()) {
76
+ const actual=nativeLines.get(`${partIndex}:${lineIndex}`);
77
+ if (actual===undefined) throw new Error('Missing metric source line.');
78
+ rebuilt+=actual.replace(/\r\n|\r|\n/g,newline)+source.slice(line.end,line.nextStart);
79
+ }
80
+ // Preserve original types for unchanged text. Canonical finite numeric edits remain
81
+ // numeric; other spellings become strings so no whitespace or precision is discarded.
82
+ value[part.role]=typeof original==='number'&&rebuilt===source?original:
83
+ typeof original==='number'&&Number.isFinite(Number(rebuilt))&&String(Number(rebuilt))===rebuilt?Number(rebuilt):rebuilt;
84
+ if (typeof original!==typeof value[part.role]) typeChanges.push(part.role);
85
+ }
86
+ const metric=scalar(manifest.value)?value.value:value;
87
+ if (!validatePresentation({slides:[{metric}]}).valid) throw new Error('Edited native text cannot form a valid metric.');
88
+ for (const item of group) consumed.add(item.shape);
89
+ items.push({shape:anchor.shape,payload:{type:'metric',metric}});
90
+ if (typeChanges.length) report({code:'metric-value-type-changed',message:`Edited numeric ${typeChanges.join(', ')} retained as literal text to avoid discarding spelling or precision.`});
91
+ report({code:'metric-import-reflow',message:'Metric fields, source boundaries and native text were recovered. Native formatting, positioning, alignment and font theme are not reconstructed; review the reflowed OPF.'});
92
+ } catch (error) {report({code:'invalid-metric-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});}
93
+ return {consumed,items};
94
+ }
@@ -0,0 +1,54 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
+ const TAG='OPF_TEXT_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
4
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
5
+ const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
6
+ export const attachPlainTextTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfText','plain text');
7
+
8
+ // Only boundaries are stored. Current native characters, including edits and
9
+ // empty lines, always supply the text; tags cannot restore discarded old words.
10
+ export function sourceLineParagraphs(ordered,paragraphs,{legacy=false}={}) {
11
+ const source=ordered.some(item=>item.data.boundary!==undefined||item.data.separator!==undefined);
12
+ if(!source&&legacy)return ordered.flatMap(item=>paragraphs[item.index]??[]);
13
+ let text='',maxFontSize=0;
14
+ for(const [index,item]of ordered.entries()) {
15
+ const {boundary,separator}=item.data,last=index===ordered.length-1;
16
+ if(typeof separator!=='string'||(boundary==='hard'?!/^(\r\n|\r|\n)$/.test(separator)||last:separator!==''||boundary!==(last?'end':'soft')))throw Error('Invalid source line boundary.');
17
+ const current=paragraphs[item.index]??[];
18
+ if(current.some(p=>p.bullet))throw Error('Tagged plain text now has native bullets.');
19
+ text+=current.map(p=>p.text).join('\n')+separator;
20
+ maxFontSize=Math.max(maxFontSize,...current.map(p=>p.maxFontSize??0));
21
+ }
22
+ return [{text,maxFontSize,bullet:false,level:0}];
23
+ }
24
+
25
+ export function importPlainTextGroups(shapes,paragraphs,relationships,entries,report) {
26
+ const groups=new Map(),consumed=new Set(),items=[];
27
+ for(const [index,shape]of shapes.entries()) {
28
+ const tags=[];let unreadable=false;
29
+ for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
30
+ const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
31
+ try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
32
+ }
33
+ for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try {
34
+ const data=decodeTextTag(tag.val);
35
+ if(data?.v!==1||typeof data.group!=='string'||data.group.length>4096||!/^slides\.\d+(?:\.[A-Za-z0-9_:+-]+)*\.text$/.test(data.group))throw Error('Invalid plain text identity.');
36
+ const group=groups.get(data.group)??[];group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
37
+ }catch{report({code:'invalid-text-provenance',message:'Invalid plain text tags; ordinary import retains current native text.'});}
38
+ }
39
+ for(const group of groups.values())try {
40
+ const count=group[0].data.count;
41
+ if(!Number.isSafeInteger(count)||count<1||count>10000||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated plain text lines.');
42
+ const ordered=Array(count);
43
+ for(const item of group) {
44
+ const data=item.data;
45
+ if(item.ambiguous||data.count!==count||!Number.isSafeInteger(data.line)||data.line<0||data.line>=count||ordered[data.line])throw Error('Ambiguous plain text line.');
46
+ ordered[data.line]=item;
47
+ }
48
+ const restored=sourceLineParagraphs(ordered,paragraphs);
49
+ for(const item of ordered)consumed.add(item.shape);
50
+ items.push({shapes:ordered.map(item=>item.shape),paragraphs:restored});
51
+ report({code:'text-import-reflow',message:'Complete tagged lines retain current native text, whitespace and authored line boundaries. Native formatting, geometry and OPF nesting are not reconstructed; review the reflowed text.'});
52
+ }catch(error){report({code:'invalid-text-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
53
+ return {consumed,items};
54
+ }
@@ -0,0 +1,81 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {validatePresentation} from '@openpresentation/opf';
3
+ import {attachTextTags,decodeTextTag} from './code-provenance.js';
4
+ import {sourceLineParagraphs} from './text-provenance.js';
5
+
6
+ const TAG='OPF_TIMELINE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
7
+ const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
8
+ const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
9
+ export const attachTimelineTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfTimeline','timeline');
10
+
11
+ // Only topology and line boundaries are retained. Every field value comes from
12
+ // current native text; clearing or editing a field cannot restore old words.
13
+ export function timelineManifest(value,layout) {
14
+ return {shorthand:Array.isArray(value),eventCount:(Array.isArray(value)?value:value.events).length,
15
+ parts:layout.parts.map(part=>({eventIndex:part.eventIndex,field:part.role==='event-description'?'description':part.role,lines:part.fit.sourceLines.length}))};
16
+ }
17
+ function validateManifest(manifest) {
18
+ if(!manifest||typeof manifest.shorthand!=='boolean'||!Number.isSafeInteger(manifest.eventCount)||manifest.eventCount<1||manifest.eventCount>10000||!Array.isArray(manifest.parts)||manifest.parts.length>10000)throw Error('Invalid timeline topology.');
19
+ const keys=[],fields=new Set();let count=0;
20
+ for(const part of manifest.parts){
21
+ if(!part||!Number.isSafeInteger(part.lines)||part.lines<1||part.lines>10000)throw Error('Invalid timeline line count.');
22
+ if(part.eventIndex===undefined){if(manifest.shorthand||!['name','description'].includes(part.field))throw Error('Invalid timeline metadata field.');}
23
+ else if(!Number.isSafeInteger(part.eventIndex)||part.eventIndex<0||part.eventIndex>=manifest.eventCount||!['when','what','description'].includes(part.field))throw Error('Invalid timeline event field.');
24
+ const key=`${part.eventIndex??'metadata'}:${part.field}`;if(fields.has(key))throw Error('Duplicated timeline field.');fields.add(key);keys.push(key);count+=part.lines;
25
+ }
26
+ if(count>10000)throw Error('Too many timeline source lines.');
27
+ const expected=['name','description'].map(field=>`metadata:${field}`).filter(key=>fields.has(key));
28
+ for(let index=0;index<manifest.eventCount;index++){
29
+ if(!fields.has(`${index}:what`))throw Error('Missing timeline event label.');
30
+ for(const field of ['when','what','description'])if(fields.has(`${index}:${field}`))expected.push(`${index}:${field}`);
31
+ }
32
+ if(JSON.stringify(keys)!==JSON.stringify(expected))throw Error('Invalid timeline source order.');
33
+ return count;
34
+ }
35
+
36
+ export function importTimelineGroups(shapes,paragraphs,relationships,entries,report) {
37
+ const groups=new Map(),consumed=new Set(),items=[];
38
+ for(const [index,shape]of shapes.entries()){
39
+ const tags=[];let unreadable=false;
40
+ for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
41
+ const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
42
+ try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
43
+ }
44
+ for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try{
45
+ const data=decodeTextTag(tag.val);if(data?.v!==1||typeof data.group!=='string'||!/^\d{1,12}$/.test(data.group))throw Error('Invalid timeline identity.');
46
+ const group=groups.get(data.group)??[];group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
47
+ }catch{report({code:'invalid-timeline-provenance',message:'Invalid timeline tags; ordinary import retains current native text.'});}
48
+ }
49
+ for(const group of groups.values())try{
50
+ const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated timeline anchor.');
51
+ const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
52
+ const total=count+manifest.eventCount+1;
53
+ if(anchor.data.role!=='text'||anchor.data.part!==0||anchor.data.line!==0||group.length!==total||new Set(group.map(item=>item.shape)).size!==total)throw Error('Incomplete or duplicated timeline elements.');
54
+ const ordered=manifest.parts.map(part=>Array(part.lines)),markers=new Set();let connectors=0;
55
+ for(const item of group){
56
+ if(item.ambiguous)throw Error('Multiple source identities on one timeline shape.');
57
+ if(item.data.role==='connector'||item.data.role==='marker'){
58
+ const expected=item.data.role==='connector'?'line':'ellipse';
59
+ if(item.shape['p:spPr']?.['a:prstGeom']?.prst!==expected||(paragraphs[item.index]??[]).some(paragraph=>paragraph.text||paragraph.bullet))throw Error('Timeline graphic now contains different geometry or native text.');
60
+ if(item.data.role==='connector'){if(++connectors>1)throw Error('Duplicated timeline connector.');}
61
+ else{const index=item.data.eventIndex;if(!Number.isSafeInteger(index)||index<0||index>=manifest.eventCount||markers.has(index))throw Error('Ambiguous timeline marker.');markers.add(index);}
62
+ continue;
63
+ }
64
+ const {part,line}=item.data;
65
+ if(item.data.role!=='text'||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||part<0||line<0||!manifest.parts[part]||line>=manifest.parts[part].lines||item.data.count!==manifest.parts[part].lines||ordered[part][line])throw Error('Ambiguous timeline source line.');
66
+ ordered[part][line]=item;
67
+ }
68
+ if(connectors!==1||markers.size!==manifest.eventCount)throw Error('Incomplete timeline graphics.');
69
+ const result={events:Array.from({length:manifest.eventCount},()=>({}))};
70
+ for(const [index,part]of manifest.parts.entries()){
71
+ const text=sourceLineParagraphs(ordered[index],paragraphs)[0].text;
72
+ if(part.eventIndex===undefined)result[part.field]=text;else result.events[part.eventIndex][part.field]=text;
73
+ }
74
+ const timeline=manifest.shorthand?result.events:result;
75
+ if(!validatePresentation({slides:[{timeline}]}).valid)throw Error('Current native text does not form a valid timeline.');
76
+ for(const item of group)consumed.add(item.shape);
77
+ items.push({shapes:group.map(item=>item.shape),payload:{type:'timeline',timeline}});
78
+ report({code:'timeline-import-reflow',message:'Timeline field order, source boundaries and current native text were recovered. Native formatting, positions, marker styling and font theme are not reconstructed; review the reflowed timeline.'});
79
+ }catch(error){report({code:'invalid-timeline-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
80
+ return {consumed,items};
81
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "engines": {
9
- "node": ">=20.9.0"
9
+ "node": "24.x"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -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",
41
- "test": "npm run build && 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",
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",
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",
@@ -46,17 +46,20 @@
46
46
  "prepack": "npm run build",
47
47
  "build:browser-check": "node scripts/build-browser-check.mjs",
48
48
  "test:styled-table": "node test/styled-table.mjs && node test/styled-table-import.mjs",
49
- "test:code": "node test/shared-code.mjs && node test/code-provenance.mjs"
49
+ "test:code": "node test/shared-code.mjs && node test/code-provenance.mjs",
50
+ "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
51
+ "test:font-variants": "node test/font-variants.mjs",
52
+ "test:timeline": "node test/shared-timeline.mjs"
50
53
  },
51
54
  "dependencies": {
52
- "@openpresentation/opf": "^0.9.0",
55
+ "@openpresentation/opf": "^0.10.0",
53
56
  "fast-xml-parser": "^5.8.0",
54
57
  "fflate": "^0.8.3",
55
58
  "jszip": "3.10.1",
56
59
  "sharp": "0.35.4"
57
60
  },
58
61
  "peerDependencies": {
59
- "@openpresentation/opf-render": "^0.7.0"
62
+ "@openpresentation/opf-render": "^0.8.0"
60
63
  },
61
64
  "peerDependenciesMeta": {
62
65
  "@openpresentation/opf-render": {
@@ -64,7 +67,7 @@
64
67
  }
65
68
  },
66
69
  "devDependencies": {
67
- "@openpresentation/opf-render": "0.7.0",
70
+ "@openpresentation/opf-render": "0.8.0",
68
71
  "esbuild": "0.28.2",
69
72
  "playwright": "1.63.0"
70
73
  },