@openpresentation/opf-pptx 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,28 @@
1
1
  # OPF PPTX
2
2
 
3
+ Version 0.8.1 requires core 0.10.1, including metric, quote and timeline layout placeholders and the corrected text-bullet contract. It retains the existing PowerPoint export APIs.
4
+
5
+ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
6
+
7
+ Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
8
+
9
+ Version 0.8.0 consumes shared heading/scalar/rich line placement, including `textRasterPadding`, through editable native line boxes without autofit. Complete heading tags retain title/subtitle/tag roles, line ordering and current edited text during import. Source boundary metadata also records hard-line separators, so complete scalar/heading groups recover current native text with spaces, tabs and exact authored line endings. Incomplete, duplicate, ambiguous or bulleted groups fall back to ordinary import with diagnostics. Legacy heading tags retain their prior native-line behavior; arbitrary formatting, nesting and geometry are not reconstructed. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
10
+
3
11
  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
12
 
5
13
  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
14
 
15
+ 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.
16
+
17
+ 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.
18
+
19
+ 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.
20
+
21
+ 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.
22
+
7
23
  ## Scope
8
24
 
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.
25
+ 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
26
 
11
27
  - Package: `@openpresentation/opf-pptx`
12
28
  - Repository: `OpenPresentation/opf-pptx`
@@ -67,7 +83,7 @@ This pass did not require an OPF schema change. The deferred full OOXML placehol
67
83
  The first importer is mechanical and schema-compatible:
68
84
 
69
85
  - 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.
86
+ - 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
87
  - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
72
88
  - 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
89
  - 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 +173,7 @@ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens
157
173
 
158
174
  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
175
 
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.
176
+ 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
177
 
162
178
  `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
179
 
@@ -218,3 +234,9 @@ Conditional borders keep the whole-table outer frame separate from interior hori
218
234
  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
235
 
220
236
  Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `npm run test:styled-table` runs export/import regressions. For local browser verification, set `OPF_CORE_ROOT` to the core repository and `OPF_RENDER_ROOT` to the coordinated renderer worktree when running `npm run build:browser-check`. Serve the repository and open `/artifacts/native-styled-table-import/browser/index.html`. Omit those variables to exercise installed package dependencies.
237
+
238
+ Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
239
+
240
+ The unpublished furniture candidate exports core's accepted header/footer text and picture geometry. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
241
+
242
+ Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; this behavior is not yet published.
@@ -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, hex as encodeTextTag};
28
+
29
+ export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false} = {}) {
24
30
  if (!records.size) return;
25
31
  let count = 0;
26
32
  const seen = new Set(), types = [];
@@ -28,26 +34,27 @@ export function attachCodeTags(entries, records) {
28
34
  const relPath = path.replace('/slides/','/slides/_rels/') + '.rels';
29
35
  let rels = dec.decode(entries[relPath]);
30
36
  const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match=>match[1]));
31
- const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape=>{
37
+ const pattern = pictures ? /<p:(sp|pic)>[\s\S]*?<\/p:\1>/g : /<p:sp>[\s\S]*?<\/p:sp>/g;
38
+ const xml = dec.decode(entries[path]).replace(pattern, shape=>{
32
39
  const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
33
40
  if (!records.has(name)) return shape;
34
- if (seen.has(name)) throw new Error('Duplicate generated code shape.');
41
+ if (seen.has(name)) throw new Error(`Duplicate generated ${kind} shape.`);
35
42
  seen.add(name);
36
- const part = `ppt/tags/opfCode${++count}.xml`;
37
- let id = `rIdOpfCode${count}`;
43
+ const part = `ppt/tags/${prefix}${++count}.xml`;
44
+ let id = `rId${prefix[0].toUpperCase()+prefix.slice(1)}${count}`;
38
45
  while (ids.has(id)) id += '_';
39
46
  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>`);
47
+ 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
48
  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>`);
49
+ rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
43
50
  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.');
51
+ if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
45
52
  return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
46
53
  });
47
54
  entries[path] = enc.encode(xml);
48
55
  entries[relPath] = enc.encode(rels);
49
56
  }
50
- if (seen.size !== records.size) throw new Error('Missing generated code shapes.');
57
+ if (seen.size !== records.size) throw new Error(`Missing generated ${kind} shapes.`);
51
58
  entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>',types.join('')+'</Types>'));
52
59
  }
53
60
 
@@ -120,10 +127,13 @@ export function importCodeGroups(shapes, paragraphs, relationships, entries, rep
120
127
  const rel = relationships.get(link['r:id']);
121
128
  if (rel?.type !== REL || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
122
129
  let tags;
123
- try { tags = array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag=>tag.name?.toUpperCase() === TAG); }
130
+ try {
131
+ const all=array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']);
132
+ tagCounts.set(shape,(tagCounts.get(shape)??0)+all.filter(tag=>/^OPF_/i.test(tag.name)).length);
133
+ tags=all.filter(tag=>tag.name?.toUpperCase()===TAG);
134
+ }
124
135
  catch { report({code:'invalid-code-provenance',message:'Unreadable code tags; visible native shapes were retained.'}); continue; }
125
136
  for (const tag of tags) {
126
- tagCounts.set(shape,(tagCounts.get(shape) ?? 0)+1);
127
137
  try {
128
138
  const data = unhex(tag.val);
129
139
  if (data.v !== 1 || typeof data.group !== 'string' || !/^\d+$/.test(data.group)) throw new Error('Invalid code identity.');
@@ -0,0 +1,214 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
3
+ import {sourceLineParagraphs} from './text-provenance.js';
4
+
5
+ const TAG = 'OPF_FURNITURE_V1';
6
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
7
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
8
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
9
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
10
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
11
+ const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
12
+ const fields = ['text', 'image', 'organization', 'section', 'slideNumber', 'date'];
13
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ const key = part => `${part.kind}.${part.zone}.${part.field}`;
15
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
16
+ const check = (condition, message) => { if (!condition) throw Error(message); };
17
+
18
+ // This manifest contains topology, identities and inactive flags only. Current
19
+ // native shapes supply all words, image bytes and alt text, even after edits.
20
+ export function furnitureManifest(presentation, slide, layout, slideIndex) {
21
+ const definitions = {};
22
+ for (const kind of kinds) {
23
+ const local = slide.design?.[kind] !== undefined;
24
+ const source = local ? slide.design[kind] : presentation.design?.[kind];
25
+ if (source === undefined) continue;
26
+ const value = source === false ? false : {};
27
+ if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
28
+ value[zone] = {};
29
+ for (const field of fields) if (source[zone][field] !== undefined) {
30
+ const current = source[zone][field];
31
+ value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
32
+ }
33
+ }
34
+ definitions[kind] = {scope: local ? 'local' : 'global', value};
35
+ }
36
+ if (!Object.keys(definitions).length) return null;
37
+ const organizations = array(presentation.organization);
38
+ const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
39
+ return {v: 1, role: 'slide', group: String(slideIndex), definitions,
40
+ ...(layout.parts.some(part => part.field === 'organization') ? {organizationId: organization?.id} : {}),
41
+ parts: layout.parts.map(part => ({kind: part.kind, zone: part.zone, field: part.field,
42
+ type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length}))};
43
+ }
44
+
45
+ export function attachFurnitureTags(entries, records, manifests) {
46
+ attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
47
+ const types = [];
48
+ for (const [path, manifest] of manifests) {
49
+ const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
50
+ const relPath = path.replace('/slides/', '/slides/_rels/') + '.rels';
51
+ let xml = dec.decode(entries[path]), rels = dec.decode(entries[relPath]);
52
+ // Common-slide customer data follows spTree, before controls/extLst.
53
+ // No invisible/fake shape is necessary for false or empty definitions.
54
+ const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
55
+ let id = 'rIdOpfFurnitureSlide';
56
+ while (ids.has(id)) id += '_';
57
+ check(!entries[part] && xml.includes('</p:spTree>'), 'Missing slide tree or colliding furniture tag part.');
58
+ xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>`);
59
+ rels = rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfFurnitureSlide${manifest.group}.xml"/></Relationships>`);
60
+ entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag(manifest)}"/></p:tagLst>`);
61
+ entries[path] = enc.encode(xml);
62
+ entries[relPath] = enc.encode(rels);
63
+ types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
64
+ }
65
+ if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
66
+ }
67
+
68
+ function readTags(container, relationships, entries) {
69
+ const tags = [];
70
+ let unreadable = false;
71
+ for (const link of array(container?.['p:tags'])) {
72
+ const rel = relationships.get(link['r:id']);
73
+ try {
74
+ check(rel?.type === REL && rel.targetMode !== 'External' && entries[rel.path], 'Missing tag relationship.');
75
+ tags.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));
76
+ } catch { unreadable = true; }
77
+ }
78
+ return {tags: tags.filter(tag => tag.name?.toUpperCase() === TAG),
79
+ ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name)).length !== 1};
80
+ }
81
+
82
+ function validateManifest(manifest) {
83
+ check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
84
+ check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
85
+ check(Array.isArray(manifest.parts) && manifest.parts.length <= 36, 'Invalid furniture parts.');
86
+ const expected = new Map();
87
+ for (const [kind, definition] of Object.entries(manifest.definitions)) {
88
+ check(object(definition) && ['global', 'local'].includes(definition.scope), 'Invalid furniture scope.');
89
+ const value = definition.value;
90
+ check(value === false || object(value), 'Invalid furniture definition.');
91
+ if (value === false) continue;
92
+ check(Object.keys(value).every(zone => zones.includes(zone)), 'Invalid furniture zone.');
93
+ for (const [zone, content] of Object.entries(value)) {
94
+ check(object(content) && Object.keys(content).every(field => fields.includes(field)), 'Invalid furniture fields.');
95
+ for (const [field, flag] of Object.entries(content)) {
96
+ check(field === 'image' ? flag === 'native' : field === 'text' ? flag === 'literal' :
97
+ field === 'date' ? flag === false || flag === 'literal' : typeof flag === 'boolean', 'Invalid furniture field intent.');
98
+ if (flag !== false) expected.set(`${kind}.${zone}.${field}`, field === 'image' ? 'image' : 'text');
99
+ }
100
+ }
101
+ }
102
+ check(manifest.parts.length === expected.size, 'Incomplete furniture topology.');
103
+ for (const part of manifest.parts) {
104
+ check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
105
+ check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
106
+ expected.delete(key(part));
107
+ }
108
+ if (manifest.parts.some(part => part.field === 'organization')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
109
+ }
110
+
111
+ function readSlide(context, entries, slideIndex, report, taggedText) {
112
+ const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
113
+ const candidates = {};
114
+ const records = [];
115
+ let invalidRecord = false;
116
+ for (const [type, nodes, properties] of [['text', shapes, 'p:nvSpPr'], ['image', pictures, 'p:nvPicPr']]) {
117
+ for (const [index, node] of nodes.entries()) {
118
+ const result = readTags(node[properties]?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
119
+ if (type === 'text' && result.tags.length) taggedText.add(index);
120
+ for (const tag of result.tags) try {
121
+ check(!result.ambiguous, 'Multiple identities on a furniture shape.');
122
+ records.push({type, index, data: decodeTextTag(tag.val)});
123
+ } catch { invalidRecord = true; }
124
+ }
125
+ }
126
+ const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
127
+ if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
128
+ let manifest;
129
+ try {
130
+ check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
131
+ manifest = decodeTextTag(tags.tags[0].val);
132
+ validateManifest(manifest);
133
+ for (const record of records) {
134
+ const data = record.data, part = manifest.parts[data?.part];
135
+ check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
136
+ }
137
+ } catch (error) { report(slideIndex, error.message); return candidates; }
138
+ for (const kind of kinds) {
139
+ const definition = manifest.definitions[kind];
140
+ if (!definition) continue;
141
+ try {
142
+ const value = definition.value === false ? false : {};
143
+ if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
144
+ value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
145
+ }
146
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], sections: []};
147
+ for (const [partIndex, part] of manifest.parts.entries()) {
148
+ if (part.kind !== kind) continue;
149
+ const group = records.filter(record => record.data.part === partIndex);
150
+ check(group.length === part.count, 'Incomplete or duplicated furniture part.');
151
+ if (part.type === 'image') {
152
+ const current = readPicture(pictures[group[0].index]);
153
+ check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
154
+ value[part.zone].image = current.payload.image;
155
+ candidate.pictures.push(group[0].index);
156
+ } else {
157
+ const ordered = Array(part.count);
158
+ for (const record of group) {
159
+ const {line, count} = record.data;
160
+ check(count === part.count && Number.isSafeInteger(line) && line >= 0 && line < part.count && !ordered[line], 'Ambiguous furniture line.');
161
+ ordered[line] = record;
162
+ }
163
+ const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
164
+ if (part.field === 'slideNumber') check(text === String(slideIndex + 1), 'Current slide number differs from its position; retain the visible number as ordinary text.');
165
+ if (part.field === 'section') candidate.sections.push(text);
166
+ if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
167
+ value[part.zone][part.field] = ['text', 'date'].includes(part.field) ? text : true;
168
+ candidate.text.push(...ordered.map(record => record.index));
169
+ }
170
+ }
171
+ check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
172
+ candidates[kind] = candidate;
173
+ } catch (error) { report(slideIndex, `${kind}: ${error.message}`); }
174
+ }
175
+ return candidates;
176
+ }
177
+
178
+ // Reconcile metadata before consuming any shapes. Global inheritance is promoted
179
+ // only when every slide has a valid definition/override and all inherited values
180
+ // agree. A missing tag must never cause another slide's furniture to reappear.
181
+ export function importFurniture(contexts, entries, onDiagnostic) {
182
+ const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
183
+ const taggedText = contexts.map(() => new Set());
184
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, report, taggedText[index]));
185
+ const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
186
+ if (organizations.some(item => !same(item, organizations[0]))) {
187
+ for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
188
+ delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
189
+ }
190
+ }
191
+ for (const [index, slide] of candidates.entries()) {
192
+ const sections = Object.values(slide).flatMap(candidate => candidate.sections);
193
+ if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
194
+ delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
195
+ }
196
+ }
197
+ const result = {design: {}, slides: candidates.map((slide, index) => {
198
+ const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
199
+ return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
200
+ ...(sections.length ? {section: sections[0]} : {}),
201
+ text: new Set(values.flatMap(candidate => candidate.text)), pictures: new Set(values.flatMap(candidate => candidate.pictures)), taggedText: taggedText[index]};
202
+ })};
203
+ const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
204
+ if (validOrganizations.length) result.organization = validOrganizations[0];
205
+ for (const kind of kinds) {
206
+ const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
207
+ if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
208
+ result.design[kind] = inherited[0].value;
209
+ candidates.forEach((slide, index) => { if (slide[kind].scope === 'global') delete result.slides[index].design[kind]; });
210
+ }
211
+ }
212
+ candidates.forEach((slide, index) => { if (Object.keys(slide).length) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
213
+ return result;
214
+ }
@@ -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;