docrev 0.8.5 → 0.9.3

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.
Files changed (60) hide show
  1. package/.gitattributes +1 -0
  2. package/README.md +25 -1
  3. package/dist/lib/annotations.d.ts.map +1 -1
  4. package/dist/lib/annotations.js +6 -0
  5. package/dist/lib/annotations.js.map +1 -1
  6. package/dist/lib/build.d.ts +6 -1
  7. package/dist/lib/build.d.ts.map +1 -1
  8. package/dist/lib/build.js +67 -1
  9. package/dist/lib/build.js.map +1 -1
  10. package/dist/lib/commands/build.d.ts.map +1 -1
  11. package/dist/lib/commands/build.js +26 -7
  12. package/dist/lib/commands/build.js.map +1 -1
  13. package/dist/lib/commands/response.d.ts.map +1 -1
  14. package/dist/lib/commands/response.js +50 -2
  15. package/dist/lib/commands/response.js.map +1 -1
  16. package/dist/lib/commands/sections.d.ts.map +1 -1
  17. package/dist/lib/commands/sections.js +28 -9
  18. package/dist/lib/commands/sections.js.map +1 -1
  19. package/dist/lib/csl.d.ts +38 -0
  20. package/dist/lib/csl.d.ts.map +1 -0
  21. package/dist/lib/csl.js +170 -0
  22. package/dist/lib/csl.js.map +1 -0
  23. package/dist/lib/import.d.ts.map +1 -1
  24. package/dist/lib/import.js +20 -7
  25. package/dist/lib/import.js.map +1 -1
  26. package/dist/lib/journals.d.ts.map +1 -1
  27. package/dist/lib/journals.js +24 -0
  28. package/dist/lib/journals.js.map +1 -1
  29. package/dist/lib/plugins.d.ts +11 -0
  30. package/dist/lib/plugins.d.ts.map +1 -1
  31. package/dist/lib/plugins.js +21 -1
  32. package/dist/lib/plugins.js.map +1 -1
  33. package/dist/lib/pptx-template.d.ts +17 -22
  34. package/dist/lib/pptx-template.d.ts.map +1 -1
  35. package/dist/lib/pptx-template.js +296 -552
  36. package/dist/lib/pptx-template.js.map +1 -1
  37. package/dist/lib/schema.d.ts.map +1 -1
  38. package/dist/lib/schema.js +4 -0
  39. package/dist/lib/schema.js.map +1 -1
  40. package/dist/lib/types.d.ts +19 -1
  41. package/dist/lib/types.d.ts.map +1 -1
  42. package/dist/lib/word.d.ts +24 -11
  43. package/dist/lib/word.d.ts.map +1 -1
  44. package/dist/lib/word.js +233 -32
  45. package/dist/lib/word.js.map +1 -1
  46. package/lib/annotations.ts +8 -0
  47. package/lib/build.ts +75 -2
  48. package/lib/commands/build.ts +25 -7
  49. package/lib/commands/response.ts +55 -2
  50. package/lib/commands/sections.ts +31 -9
  51. package/lib/csl.ts +191 -0
  52. package/lib/import.ts +21 -7
  53. package/lib/journals.ts +25 -1
  54. package/lib/plugins.ts +35 -1
  55. package/lib/pptx-template.ts +346 -502
  56. package/lib/schema.ts +4 -0
  57. package/lib/types.ts +20 -1
  58. package/lib/word.ts +253 -38
  59. package/package.json +1 -2
  60. package/lib/apply-buildup-colors.py +0 -88
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * PPTX post-processing
3
3
  *
4
- * Injects logos into each slide of a generated PPTX to match ref.pptx styling.
5
- * Uses ref.pptx as-is for --reference-doc, then post-processes to add logos.
4
+ * Pure TypeScript implementation using AdmZip for in-memory ZIP/PPTX manipulation.
5
+ * No Python dependency required.
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'node:fs';
9
- import { join, basename, extname, dirname } from 'node:path';
10
- import { execSync } from 'node:child_process';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import AdmZip from 'adm-zip';
11
11
 
12
12
  interface ThemeFonts {
13
13
  major?: string;
@@ -31,303 +31,45 @@ interface BuildupConfig {
31
31
  enabled?: boolean;
32
32
  }
33
33
 
34
- /**
35
- * Extract PPTX to directory
36
- */
37
- async function extractPptx(pptxPath: string, destDir: string): Promise<void> {
38
- if (process.platform === 'win32') {
39
- const zipPath = pptxPath.replace(/\.pptx$/i, '.zip');
40
- const content = readFileSync(pptxPath);
41
- writeFileSync(zipPath, content);
42
- try {
43
- execSync(`powershell -Command "Expand-Archive -LiteralPath '${zipPath}' -DestinationPath '${destDir}' -Force"`, { stdio: 'pipe' });
44
- } finally {
45
- try { unlinkSync(zipPath); } catch { /* ignore */ }
46
- }
47
- } else {
48
- execSync(`unzip -q "${pptxPath}" -d "${destDir}"`, { stdio: 'pipe' });
49
- }
34
+ // =============================================================================
35
+ // Shared Helpers
36
+ // =============================================================================
37
+
38
+ function getSlideEntries(zip: AdmZip) {
39
+ return zip.getEntries()
40
+ .filter(e => /^ppt\/slides\/slide\d+\.xml$/.test(e.entryName))
41
+ .sort((a, b) => {
42
+ const na = parseInt(a.entryName.match(/slide(\d+)/)?.[1] || '0');
43
+ const nb = parseInt(b.entryName.match(/slide(\d+)/)?.[1] || '0');
44
+ return na - nb;
45
+ });
50
46
  }
51
47
 
52
- /**
53
- * Create PPTX from directory
54
- * Uses same compression settings as the original file
55
- */
56
- async function createPptx(srcDir: string, pptxPath: string): Promise<void> {
57
- const scriptPath = join(dirname(pptxPath), '.zip-create.py');
58
- const script = `import zipfile, os, sys
59
-
60
- src, dst = sys.argv[1], sys.argv[2]
61
-
62
- # Collect all files
63
- files_to_add = []
64
- for root, dirs, files in os.walk(src):
65
- for f in files:
66
- fp = os.path.join(root, f)
67
- arcname = os.path.relpath(fp, src).replace(os.sep, '/')
68
- files_to_add.append((fp, arcname))
69
-
70
- # Write ZIP with DEFLATED compression
71
- with zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
72
- for fp, arcname in files_to_add:
73
- zf.write(fp, arcname)
74
- `;
75
-
76
- writeFileSync(scriptPath, script);
77
- try {
78
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
79
- execSync(`${pythonCmd} "${scriptPath}" "${srcDir}" "${pptxPath}"`, { stdio: 'pipe' });
80
- } finally {
81
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
82
- }
48
+ function readEntry(zip: AdmZip, name: string): string {
49
+ return zip.getEntry(name)?.getData().toString('utf-8') ?? '';
83
50
  }
84
51
 
85
- /**
86
- * Recursively remove directory
87
- */
88
- function rmSync(path: string, options?: { recursive?: boolean }): void {
89
- const fs = require('node:fs');
90
- if (fs.rmSync) {
91
- fs.rmSync(path, options);
92
- } else {
93
- const items = fs.readdirSync(path);
94
- for (const item of items) {
95
- const itemPath = join(path, item);
96
- if (fs.statSync(itemPath).isDirectory()) {
97
- rmSync(itemPath, options);
98
- } else {
99
- fs.unlinkSync(itemPath);
100
- }
101
- }
102
- fs.rmdirSync(path);
103
- }
52
+ function updateEntry(zip: AdmZip, name: string, content: string): void {
53
+ zip.updateFile(name, Buffer.from(content, 'utf-8'));
104
54
  }
105
55
 
106
- /**
107
- * Inject slide numbers into each slide of a PPTX
108
- * Only adds slide numbers to slides that have a footer (i.e., slides with the green banner).
109
- * Title slides, section slides, cover slides don't have the banner so they don't get numbers.
110
- * Uses in-place ZIP modification to preserve file structure.
111
- */
112
- export async function injectSlideNumbers(pptxPath: string): Promise<void> {
113
- if (!existsSync(pptxPath)) return;
114
-
115
- const scriptPath = join(dirname(pptxPath), '.inject-slidenum.py');
116
- const script = `import zipfile, sys, re, os
117
-
118
- pptx_path = sys.argv[1]
119
- temp_path = pptx_path + '.tmp'
120
-
121
- # Slide number XML template with manual number (white text, 16pt)
122
- def get_slidenum_xml(max_id, num):
123
- return f'<p:sp><p:nvSpPr><p:cNvPr id="{max_id}" name="Slide Number Placeholder {max_id}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldNum" sz="quarter" idx="12"/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm><a:off x="8610600" y="6581838"/><a:ext cx="2743200" cy="319024"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr><a:defRPr sz="1600"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:defRPr></a:lvl1pPr></a:lstStyle><a:p><a:r><a:rPr lang="en-GB" sz="1600" dirty="0"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:rPr><a:t>{num}</a:t></a:r></a:p></p:txBody></p:sp>'
124
-
125
- def is_content_slide(text):
126
- """Check if slide is a content slide (has footer AND body placeholder)"""
127
- has_footer = 'type="ftr"' in text
128
- has_body = 'idx="1"' in text or 'type="body"' in text
129
- return has_footer and has_body
130
-
131
- # First pass: identify content slides and assign sequential numbers
132
- with zipfile.ZipFile(pptx_path, 'r') as zin:
133
- slide_numbers = {} # filename -> sequential number
134
- content_num = 1
135
-
136
- # Get all slide files sorted by number
137
- slide_files = sorted([f for f in zin.namelist()
138
- if f.startswith('ppt/slides/slide') and f.endswith('.xml')],
139
- key=lambda x: int(re.search(r'slide(\\d+)', x).group(1)))
140
-
141
- for fname in slide_files:
142
- text = zin.read(fname).decode('utf-8')
143
- if is_content_slide(text) and 'type="sldNum"' not in text:
144
- slide_numbers[fname] = content_num
145
- content_num += 1
146
-
147
- # Second pass: inject numbers
148
- with zipfile.ZipFile(pptx_path, 'r') as zin:
149
- with zipfile.ZipFile(temp_path, 'w') as zout:
150
- for item in zin.infolist():
151
- content = zin.read(item.filename)
152
-
153
- if item.filename in slide_numbers:
154
- text = content.decode('utf-8')
155
- # Find max id
156
- ids = [int(m) for m in re.findall(r'id="(\\d+)"', text)]
157
- max_id = max(ids) + 1 if ids else 100
158
-
159
- # Insert slide number with sequential count
160
- slidenum_xml = get_slidenum_xml(max_id, slide_numbers[item.filename])
161
- text = text.replace('</p:spTree>', slidenum_xml + '</p:spTree>')
162
- content = text.encode('utf-8')
163
-
164
- zout.writestr(item, content)
165
-
166
- os.replace(temp_path, pptx_path)
167
- `;
168
-
169
- writeFileSync(scriptPath, script);
170
- try {
171
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
172
- execSync(`${pythonCmd} "${scriptPath}" "${pptxPath}"`, { stdio: 'pipe' });
173
- } finally {
174
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
175
- }
56
+ function findMaxId(xml: string): number {
57
+ const ids = [...xml.matchAll(/id="(\d+)"/g)].map(m => parseInt(m[1]));
58
+ return ids.length ? Math.max(...ids) : 0;
176
59
  }
177
60
 
178
- /**
179
- * Inject logos into cover slide of a PPTX (matching ref.pptx style)
180
- * Uses in-place ZIP modification to preserve file structure.
181
- */
182
- export async function injectLogosIntoSlides(pptxPath: string, mediaDir: string | null): Promise<void> {
183
- if (!mediaDir || !existsSync(mediaDir) || !existsSync(pptxPath)) return;
184
-
185
- // Check for logo files
186
- const logoLeft = join(mediaDir, 'logo-left.png');
187
- const logoRight = join(mediaDir, 'logo-right.png');
188
-
189
- const hasLeft = existsSync(logoLeft);
190
- const hasRight = existsSync(logoRight);
191
-
192
- if (!hasLeft && !hasRight) return;
193
-
194
- // Read logo files as base64
195
- const logoLeftData = hasLeft ? readFileSync(logoLeft).toString('base64') : '';
196
- const logoRightData = hasRight ? readFileSync(logoRight).toString('base64') : '';
197
-
198
- const scriptPath = join(dirname(pptxPath), '.inject-logos.py');
199
- const script = `import zipfile, sys, re, os, base64
200
-
201
- pptx_path = sys.argv[1]
202
- has_left = ${hasLeft ? 'True' : 'False'}
203
- has_right = ${hasRight ? 'True' : 'False'}
204
- logo_left_b64 = """${logoLeftData}"""
205
- logo_right_b64 = """${logoRightData}"""
206
-
207
- temp_path = pptx_path + '.tmp'
208
-
209
- # Find next available image number
210
- def get_next_image_num(zf):
211
- max_num = 0
212
- for name in zf.namelist():
213
- m = re.match(r'ppt/media/image(\\d+)\\.', name)
214
- if m:
215
- max_num = max(max_num, int(m.group(1)))
216
- return max_num + 1
217
-
218
- with zipfile.ZipFile(pptx_path, 'r') as zin:
219
- next_img = get_next_image_num(zin)
220
- right_img_name = f'ppt/media/image{next_img}.png' if has_right else None
221
- left_img_name = f'ppt/media/image{next_img + 1}.png' if has_left else None
222
-
223
- with zipfile.ZipFile(temp_path, 'w') as zout:
224
- for item in zin.infolist():
225
- content = zin.read(item.filename)
226
-
227
- # Update [Content_Types].xml to include png if needed
228
- if item.filename == '[Content_Types].xml':
229
- text = content.decode('utf-8')
230
- if 'Extension="png"' not in text:
231
- text = text.replace('</Types>', '<Default Extension="png" ContentType="image/png"/></Types>')
232
- content = text.encode('utf-8')
233
-
234
- # Update slide1.xml.rels to add image relationships
235
- if item.filename == 'ppt/slides/_rels/slide1.xml.rels':
236
- text = content.decode('utf-8')
237
- # Find max rId
238
- rids = [int(m) for m in re.findall(r'Id="rId(\\d+)"', text)]
239
- max_rid = max(rids) if rids else 0
240
-
241
- new_rels = []
242
- right_rid = None
243
- left_rid = None
244
-
245
- if has_right:
246
- max_rid += 1
247
- right_rid = f'rId{max_rid}'
248
- new_rels.append(f'<Relationship Id="{right_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{next_img}.png"/>')
249
-
250
- if has_left:
251
- max_rid += 1
252
- left_rid = f'rId{max_rid}'
253
- new_rels.append(f'<Relationship Id="{left_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image{next_img + 1}.png"/>')
254
-
255
- if new_rels:
256
- text = text.replace('</Relationships>', ''.join(new_rels) + '</Relationships>')
257
- content = text.encode('utf-8')
258
-
259
- # Store rIds for slide1 modification
260
- zout.right_rid = right_rid
261
- zout.left_rid = left_rid
262
-
263
- # Update slide1.xml to add picture elements
264
- if item.filename == 'ppt/slides/slide1.xml':
265
- text = content.decode('utf-8')
266
- # Find max id
267
- ids = [int(m) for m in re.findall(r'id="(\\d+)"', text)]
268
- max_id = max(ids) if ids else 0
269
-
270
- pics = []
271
- right_rid = getattr(zout, 'right_rid', None)
272
- left_rid = getattr(zout, 'left_rid', None)
273
-
274
- if has_right and right_rid:
275
- max_id += 1
276
- pics.append(f'<p:pic><p:nvPicPr><p:cNvPr id="{max_id}" name="Picture {max_id}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="{right_rid}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="9492000" y="5742001"/><a:ext cx="2700000" cy="1115999"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>')
277
-
278
- if has_left and left_rid:
279
- max_id += 1
280
- pics.append(f'<p:pic><p:nvPicPr><p:cNvPr id="{max_id}" name="Picture {max_id}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="{left_rid}"/><a:srcRect t="22495" b="27262"/><a:stretch/></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="5904608"/><a:ext cx="3794408" cy="954349"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>')
281
-
282
- if pics:
283
- text = text.replace('</p:spTree>', ''.join(pics) + '</p:spTree>')
284
- content = text.encode('utf-8')
285
-
286
- zout.writestr(item, content)
287
-
288
- # Add logo image files
289
- if has_right:
290
- zout.writestr(right_img_name, base64.b64decode(logo_right_b64))
291
- if has_left:
292
- zout.writestr(left_img_name, base64.b64decode(logo_left_b64))
293
-
294
- os.replace(temp_path, pptx_path)
295
- `;
296
-
297
- writeFileSync(scriptPath, script);
298
- try {
299
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
300
- execSync(`${pythonCmd} "${scriptPath}" "${pptxPath}"`, { stdio: 'pipe' });
301
- } finally {
302
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
303
- }
61
+ function findMaxRId(xml: string): number {
62
+ const rids = [...xml.matchAll(/Id="rId(\d+)"/g)].map(m => parseInt(m[1]));
63
+ return rids.length ? Math.max(...rids) : 0;
304
64
  }
305
65
 
306
- // Legacy exports for compatibility
307
- export async function generatePptxTemplate(options: TemplateOptions): Promise<string | null> {
308
- // No longer modifying template - just return the base template path
309
- const { baseTemplate, outputPath } = options;
310
- if (baseTemplate && existsSync(baseTemplate)) {
311
- // Copy base template to output
312
- writeFileSync(outputPath, readFileSync(baseTemplate));
313
- return outputPath;
314
- }
315
- return null;
316
- }
317
-
318
- export function templateNeedsRegeneration(templatePath: string, mediaDir: string, baseTemplate: string): boolean {
319
- return false; // No template regeneration needed - we use ref.pptx as-is
320
- }
321
-
322
- export async function injectMediaIntoPptx(pptxPath: string, mediaDir: string): Promise<void> {
323
- // Redirect to the new function
324
- return injectLogosIntoSlides(pptxPath, mediaDir);
325
- }
66
+ // =============================================================================
67
+ // 1. Apply Theme Fonts
68
+ // =============================================================================
326
69
 
327
70
  /**
328
- * Apply theme fonts to all text in a PPTX
71
+ * Apply theme fonts to all text in a PPTX.
329
72
  * Pandoc generates slides with hardcoded fonts; this replaces them with theme font references.
330
- * Uses in-place ZIP modification to preserve file structure.
331
73
  */
332
74
  export async function applyThemeFonts(pptxPath: string, theme: Theme): Promise<void> {
333
75
  if (!existsSync(pptxPath) || !theme || !theme.fonts) return;
@@ -335,291 +77,393 @@ export async function applyThemeFonts(pptxPath: string, theme: Theme): Promise<v
335
77
  const { major, minor } = theme.fonts;
336
78
  if (!major && !minor) return;
337
79
 
338
- const scriptPath = join(dirname(pptxPath), '.apply-fonts.py');
339
- const script = `import zipfile, sys, re, os
340
-
341
- pptx_path = sys.argv[1]
342
- temp_path = pptx_path + '.tmp'
343
-
344
- # Fonts to replace with theme fonts
345
- default_fonts = ['Calibri', 'Arial', 'Helvetica', 'Times New Roman', 'Cambria']
346
-
347
- with zipfile.ZipFile(pptx_path, 'r') as zin:
348
- with zipfile.ZipFile(temp_path, 'w') as zout:
349
- for item in zin.infolist():
350
- content = zin.read(item.filename)
351
-
352
- # Process slide XML files
353
- if item.filename.startswith('ppt/slides/slide') and item.filename.endswith('.xml'):
354
- text = content.decode('utf-8')
355
-
356
- # Replace common pandoc fonts with theme minor font reference
357
- for font in default_fonts:
358
- text = re.sub(rf'(<a:latin\\s+typeface="){font}(")', r'\\1+mn-lt\\2', text)
359
-
360
- content = text.encode('utf-8')
361
-
362
- zout.writestr(item, content)
363
-
364
- os.replace(temp_path, pptx_path)
365
- `;
80
+ const zip = new AdmZip(pptxPath);
81
+ const defaultFonts = ['Calibri', 'Arial', 'Helvetica', 'Times New Roman', 'Cambria'];
366
82
 
367
- writeFileSync(scriptPath, script);
368
- try {
369
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
370
- execSync(`${pythonCmd} "${scriptPath}" "${pptxPath}"`, { stdio: 'pipe' });
371
- } finally {
372
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
83
+ for (const entry of getSlideEntries(zip)) {
84
+ let text = entry.getData().toString('utf-8');
85
+ for (const font of defaultFonts) {
86
+ text = text.replace(
87
+ new RegExp(`(<a:latin\\s+typeface=")${font}(")`, 'g'),
88
+ '$1+mn-lt$2'
89
+ );
90
+ }
91
+ updateEntry(zip, entry.entryName, text);
373
92
  }
93
+
94
+ zip.writeZip(pptxPath);
374
95
  }
375
96
 
97
+ // =============================================================================
98
+ // 2. Apply Centering
99
+ // =============================================================================
100
+
376
101
  /**
377
- * Apply vertical centering to slides that have the .center class
378
- * Uses in-place ZIP modification to preserve file structure.
102
+ * Apply horizontal centering to slides that have the .center class.
379
103
  */
380
104
  export async function applyCentering(pptxPath: string, centeredSlideIndices: number[]): Promise<void> {
381
105
  if (!existsSync(pptxPath) || !centeredSlideIndices || centeredSlideIndices.length === 0) return;
382
106
 
383
- const scriptPath = join(dirname(pptxPath), '.apply-centering.py');
384
- const indicesJson = JSON.stringify(centeredSlideIndices);
385
- const script = `import zipfile, sys, re, os, json
107
+ const zip = new AdmZip(pptxPath);
108
+ const centeredFiles = new Set(centeredSlideIndices.map(i => `ppt/slides/slide${i}.xml`));
109
+
110
+ for (const entry of getSlideEntries(zip)) {
111
+ if (!centeredFiles.has(entry.entryName)) continue;
386
112
 
387
- pptx_path = sys.argv[1]
388
- centered_indices = json.loads('${indicesJson}')
389
- temp_path = pptx_path + '.tmp'
113
+ let text = entry.getData().toString('utf-8');
390
114
 
391
- # Build set of slide filenames to center
392
- centered_files = {f'ppt/slides/slide{i}.xml' for i in centered_indices}
115
+ // Process each shape separately to skip footer and slide number
116
+ text = text.replace(/<p:sp>.*?<\/p:sp>/gs, (shape) => {
117
+ // Skip footer and slide number placeholders
118
+ if (shape.includes('type="sldNum"') || shape.includes('type="ftr"')) {
119
+ return shape;
120
+ }
121
+
122
+ // Add algn="ctr" to existing <a:pPr> elements
123
+ shape = shape.replace(
124
+ /(<a:pPr)((?:[^/>]|\/(?!>))*)(\s*\/?>)/g,
125
+ (_match: string, before: string, attrs: string, closing: string) => {
126
+ attrs = attrs.trimEnd();
127
+ let isSelfClosing = closing.includes('/');
393
128
 
394
- with zipfile.ZipFile(pptx_path, 'r') as zin:
395
- with zipfile.ZipFile(temp_path, 'w') as zout:
396
- for item in zin.infolist():
397
- content = zin.read(item.filename)
129
+ if (attrs.endsWith('/')) {
130
+ attrs = attrs.slice(0, -1).trimEnd();
131
+ isSelfClosing = true;
132
+ }
398
133
 
399
- # Process centered slides
400
- if item.filename in centered_files:
401
- text = content.decode('utf-8')
134
+ if (!attrs.includes('algn=')) {
135
+ attrs += ' algn="ctr"';
136
+ } else {
137
+ attrs = attrs.replace(/algn="[^"]*"/, 'algn="ctr"');
138
+ }
402
139
 
403
- # Process each shape (<p:sp>) separately to skip footer and slide number
404
- def process_shape(shape_match):
405
- shape = shape_match.group(0)
406
- # Skip footer and slide number placeholders
407
- if 'type="sldNum"' in shape or 'type="ftr"' in shape:
408
- return shape
140
+ return before + attrs + (isSelfClosing ? ' />' : '>');
141
+ }
142
+ );
409
143
 
410
- # Add algn="ctr" to existing <a:pPr> elements
411
- # Handle both <a:pPr ...> and <a:pPr ... /> (self-closing)
412
- def add_center_align(match):
413
- before, attrs, closing = match.groups()
414
- attrs = attrs.rstrip()
415
- is_self_closing = '/' in closing
144
+ // Add <a:pPr algn="ctr"/> to paragraphs without pPr
145
+ shape = shape.replace(/(<a:p>)(<a:r>)/g, '$1<a:pPr algn="ctr"/>$2');
416
146
 
417
- if attrs.endswith('/'):
418
- attrs = attrs[:-1].rstrip()
419
- is_self_closing = True
147
+ return shape;
148
+ });
149
+
150
+ updateEntry(zip, entry.entryName, text);
151
+ }
420
152
 
421
- if 'algn=' not in attrs:
422
- attrs += ' algn="ctr"'
423
- else:
424
- attrs = re.sub(r'algn="[^"]*"', 'algn="ctr"', attrs)
153
+ zip.writeZip(pptxPath);
154
+ }
425
155
 
426
- return before + attrs + (' />' if is_self_closing else '>')
156
+ // =============================================================================
157
+ // 3. Inject Slide Numbers
158
+ // =============================================================================
427
159
 
428
- shape = re.sub(r'(<a:pPr)((?:[^/>]|/(?!>))*)(\\s*/?>)', add_center_align, shape)
160
+ function getSlideNumXml(maxId: number, num: number): string {
161
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${maxId}" name="Slide Number Placeholder ${maxId}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldNum" sz="quarter" idx="12"/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm><a:off x="8610600" y="6581838"/><a:ext cx="2743200" cy="319024"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr><a:defRPr sz="1600"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:defRPr></a:lvl1pPr></a:lstStyle><a:p><a:r><a:rPr lang="en-GB" sz="1600" dirty="0"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:rPr><a:t>${num}</a:t></a:r></a:p></p:txBody></p:sp>`;
162
+ }
429
163
 
430
- # Add <a:pPr algn="ctr"/> to paragraphs without pPr
431
- shape = re.sub(r'(<a:p>)(<a:r>)', r'\\1<a:pPr algn="ctr"/>\\2', shape)
164
+ function isContentSlide(text: string): boolean {
165
+ const hasFooter = text.includes('type="ftr"');
166
+ const hasBody = text.includes('idx="1"') || text.includes('type="body"');
167
+ return hasFooter && hasBody;
168
+ }
432
169
 
433
- return shape
170
+ /**
171
+ * Inject slide numbers into content slides of a PPTX.
172
+ * Only adds numbers to slides that have a footer and body placeholder.
173
+ * Title, section, and cover slides are skipped.
174
+ */
175
+ export async function injectSlideNumbers(pptxPath: string): Promise<void> {
176
+ if (!existsSync(pptxPath)) return;
434
177
 
435
- # Process all shapes
436
- text = re.sub(r'<p:sp>.*?</p:sp>', process_shape, text, flags=re.DOTALL)
178
+ const zip = new AdmZip(pptxPath);
179
+ const slides = getSlideEntries(zip);
437
180
 
438
- content = text.encode('utf-8')
181
+ // Pass 1: identify content slides and assign sequential numbers
182
+ const slideNumbers = new Map<string, number>();
183
+ let contentNum = 1;
439
184
 
440
- zout.writestr(item, content)
185
+ for (const entry of slides) {
186
+ const text = entry.getData().toString('utf-8');
187
+ if (isContentSlide(text) && !text.includes('type="sldNum"')) {
188
+ slideNumbers.set(entry.entryName, contentNum);
189
+ contentNum++;
190
+ }
191
+ }
441
192
 
442
- os.replace(temp_path, pptx_path)
443
- `;
193
+ // Pass 2: inject numbers
194
+ for (const entry of slides) {
195
+ const num = slideNumbers.get(entry.entryName);
196
+ if (num === undefined) continue;
444
197
 
445
- writeFileSync(scriptPath, script);
446
- try {
447
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
448
- execSync(`${pythonCmd} "${scriptPath}" "${pptxPath}"`, { stdio: 'pipe' });
449
- } finally {
450
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
198
+ let text = entry.getData().toString('utf-8');
199
+ const maxId = findMaxId(text) + 1;
200
+ const slideNumXml = getSlideNumXml(maxId, num);
201
+ text = text.replace('</p:spTree>', slideNumXml + '</p:spTree>');
202
+ updateEntry(zip, entry.entryName, text);
451
203
  }
204
+
205
+ zip.writeZip(pptxPath);
452
206
  }
453
207
 
208
+ // =============================================================================
209
+ // 4. Inject Logos Into Slides
210
+ // =============================================================================
211
+
454
212
  /**
455
- * Apply buildup greying to slides with buildup content
456
- * Greys out all bullet items except the last one, which gets the accent color.
457
- * Only affects actual bullet items (not intro text with buNone).
458
- * Uses in-place ZIP modification to preserve file structure.
213
+ * Inject logos into cover slide of a PPTX (matching ref.pptx style).
459
214
  */
460
- export async function applyBuildupColors(pptxPath: string, config: BuildupConfig = {}): Promise<void> {
461
- if (!existsSync(pptxPath)) return;
215
+ export async function injectLogosIntoSlides(pptxPath: string, mediaDir: string | null): Promise<void> {
216
+ if (!mediaDir || !existsSync(mediaDir) || !existsSync(pptxPath)) return;
462
217
 
463
- // Check if buildup colors are disabled
464
- if (config.enabled === false) return;
218
+ const logoLeftPath = join(mediaDir, 'logo-left.png');
219
+ const logoRightPath = join(mediaDir, 'logo-right.png');
220
+ const hasLeft = existsSync(logoLeftPath);
221
+ const hasRight = existsSync(logoRightPath);
465
222
 
466
- // Get colors from config with defaults
467
- const defaultColor = (config.default || '608C32').replace(/^#/, '');
468
- const titleColor = (config.title || defaultColor).replace(/^#/, '');
469
- const greyColor = (config.grey || '888888').replace(/^#/, '');
470
- const accentColor = (config.accent || defaultColor).replace(/^#/, '');
223
+ if (!hasLeft && !hasRight) return;
471
224
 
472
- const scriptPath = join(dirname(pptxPath), '.apply-buildup.py');
473
- const script = `import zipfile
474
- import sys
475
- import re
476
- import os
225
+ const zip = new AdmZip(pptxPath);
477
226
 
478
- pptx_path = sys.argv[1]
479
- temp_path = pptx_path + '.tmp'
227
+ // Find next available image number
228
+ let maxImgNum = 0;
229
+ for (const entry of zip.getEntries()) {
230
+ const m = entry.entryName.match(/^ppt\/media\/image(\d+)\./);
231
+ if (m) maxImgNum = Math.max(maxImgNum, parseInt(m[1]));
232
+ }
233
+ const nextImg = maxImgNum + 1;
480
234
 
481
- DEFAULT = '${defaultColor}'
482
- TITLE = '${titleColor}'
483
- GREY = '${greyColor}'
484
- ACCENT = '${accentColor}'
235
+ const rightImgName = hasRight ? `ppt/media/image${nextImg}.png` : null;
236
+ const leftImgName = hasLeft ? `ppt/media/image${nextImg + 1}.png` : null;
485
237
 
486
- def get_bullet_paragraphs(body):
487
- """Return indices of paragraphs that are actual bullet items (have lvl="0" but NOT buNone)"""
488
- paras = list(re.finditer(r'<a:p>.*?</a:p>', body, re.DOTALL))
489
- bullet_indices = []
238
+ // Update [Content_Types].xml to include png if needed
239
+ const contentTypesXml = readEntry(zip, '[Content_Types].xml');
240
+ if (contentTypesXml && !contentTypesXml.includes('Extension="png"')) {
241
+ updateEntry(zip, '[Content_Types].xml',
242
+ contentTypesXml.replace('</Types>', '<Default Extension="png" ContentType="image/png"/></Types>')
243
+ );
244
+ }
490
245
 
491
- for i, p in enumerate(paras):
492
- para_text = p.group(0)
493
- if 'lvl="0"' in para_text and '<a:buNone' not in para_text:
494
- bullet_indices.append(i)
246
+ // Update slide1.xml.rels to add image relationships
247
+ let rightRid: string | null = null;
248
+ let leftRid: string | null = null;
249
+ const relsName = 'ppt/slides/_rels/slide1.xml.rels';
250
+ let relsXml = readEntry(zip, relsName);
495
251
 
496
- return bullet_indices, paras
252
+ if (relsXml) {
253
+ let maxRid = findMaxRId(relsXml);
254
+ const newRels: string[] = [];
497
255
 
256
+ if (hasRight) {
257
+ maxRid++;
258
+ rightRid = `rId${maxRid}`;
259
+ newRels.push(`<Relationship Id="${rightRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image${nextImg}.png"/>`);
260
+ }
498
261
 
499
- def apply_color_to_para(para, color):
500
- """Apply a color to all text runs in a paragraph"""
501
- new_para = re.sub(
502
- r'<a:rPr\\s*/>',
503
- f'<a:rPr><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:rPr>',
504
- para
505
- )
262
+ if (hasLeft) {
263
+ maxRid++;
264
+ leftRid = `rId${maxRid}`;
265
+ newRels.push(`<Relationship Id="${leftRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image${nextImg + 1}.png"/>`);
266
+ }
506
267
 
507
- def fix_rpr_with_attrs(m):
508
- attrs = m.group(1).strip()
509
- return f'<a:rPr {attrs}><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:rPr>'
268
+ if (newRels.length > 0) {
269
+ relsXml = relsXml.replace('</Relationships>', newRels.join('') + '</Relationships>');
270
+ updateEntry(zip, relsName, relsXml);
271
+ }
272
+ }
510
273
 
511
- new_para = re.sub(
512
- r'<a:rPr\\s+([^>]+?)\\s*/>',
513
- fix_rpr_with_attrs,
514
- new_para
515
- )
274
+ // Update slide1.xml to add picture elements
275
+ let slide1Xml = readEntry(zip, 'ppt/slides/slide1.xml');
276
+ if (slide1Xml) {
277
+ let maxId = findMaxId(slide1Xml);
278
+ const pics: string[] = [];
516
279
 
517
- return new_para
280
+ if (hasRight && rightRid) {
281
+ maxId++;
282
+ pics.push(`<p:pic><p:nvPicPr><p:cNvPr id="${maxId}" name="Picture ${maxId}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="${rightRid}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="9492000" y="5742001"/><a:ext cx="2700000" cy="1115999"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`);
283
+ }
518
284
 
285
+ if (hasLeft && leftRid) {
286
+ maxId++;
287
+ pics.push(`<p:pic><p:nvPicPr><p:cNvPr id="${maxId}" name="Picture ${maxId}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="${leftRid}"/><a:srcRect t="22495" b="27262"/><a:stretch/></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="5904608"/><a:ext cx="3794408" cy="954349"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`);
288
+ }
519
289
 
520
- def is_buildup_slide(xml):
521
- """Check if slide has buildup marker (animEffect with filter=wipe)"""
522
- # Buildup slides have animation effects from pandoc's incremental lists
523
- return 'animEffect' in xml or '<a:bldLst>' in xml
290
+ if (pics.length > 0) {
291
+ slide1Xml = slide1Xml.replace('</p:spTree>', pics.join('') + '</p:spTree>');
292
+ updateEntry(zip, 'ppt/slides/slide1.xml', slide1Xml);
293
+ }
294
+ }
524
295
 
296
+ // Add logo image files
297
+ if (hasRight && rightImgName) {
298
+ zip.addFile(rightImgName, readFileSync(logoRightPath));
299
+ }
300
+ if (hasLeft && leftImgName) {
301
+ zip.addFile(leftImgName, readFileSync(logoLeftPath));
302
+ }
525
303
 
526
- def color_content_placeholder(xml):
527
- """Apply colors to all text in content placeholder.
304
+ zip.writeZip(pptxPath);
305
+ }
528
306
 
529
- For buildup slides: grey previous bullet items, accent on last bullet item.
530
- For all text (bullets and non-bullets): apply default color unless overridden by buildup.
531
- """
307
+ // =============================================================================
308
+ // 5. Apply Buildup Colors
309
+ // =============================================================================
532
310
 
533
- pattern = r'(<p:sp>.*?<p:ph idx="1"[^/]*/?>.*?<p:txBody>)(.*?)(</p:txBody>.*?</p:sp>)'
534
- match = re.search(pattern, xml, re.DOTALL)
311
+ function applyColorToPara(para: string, color: string): string {
312
+ const fill = `<a:solidFill><a:srgbClr val="${color}"/></a:solidFill>`;
535
313
 
536
- if not match:
537
- return xml
314
+ // Replace bare <a:rPr/> with colored version
315
+ let result = para.replace(/<a:rPr\s*\/>/g, `<a:rPr>${fill}</a:rPr>`);
538
316
 
539
- before_body = match.group(1)
540
- body = match.group(2)
541
- after_body = match.group(3)
317
+ // Replace <a:rPr attrs/> (self-closing with attributes) with colored version
318
+ result = result.replace(/<a:rPr\s+([^>]+?)\s*\/>/g, (_: string, attrs: string) => {
319
+ return `<a:rPr ${attrs.trim()}>${fill}</a:rPr>`;
320
+ });
542
321
 
543
- bullet_indices, paras = get_bullet_paragraphs(body)
544
- is_buildup = is_buildup_slide(xml)
322
+ return result;
323
+ }
545
324
 
546
- new_body = body
547
- offset = 0
325
+ function isBuildupSlide(xml: string): boolean {
326
+ return xml.includes('animEffect') || xml.includes('<a:bldLst>');
327
+ }
548
328
 
549
- for i, para_match in enumerate(paras):
550
- start = para_match.start() + offset
551
- end = para_match.end() + offset
552
- para = para_match.group(0)
329
+ function getBulletParagraphs(body: string): {
330
+ bulletIndices: number[];
331
+ paras: { start: number; end: number; text: string }[];
332
+ } {
333
+ const paraRegex = /<a:p>.*?<\/a:p>/gs;
334
+ const paras: { start: number; end: number; text: string }[] = [];
335
+ let m: RegExpExecArray | null;
336
+ while ((m = paraRegex.exec(body)) !== null) {
337
+ paras.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
338
+ }
553
339
 
554
- # Determine color for this paragraph
555
- if i in bullet_indices and is_buildup:
556
- # Buildup bullet: grey all but last, accent on last
557
- if i == bullet_indices[-1]:
558
- color = ACCENT
559
- else:
560
- color = GREY
561
- else:
562
- # Non-bullet text OR non-buildup slide: use default color
563
- color = DEFAULT
340
+ const bulletIndices: number[] = [];
341
+ for (let i = 0; i < paras.length; i++) {
342
+ const paraText = paras[i].text;
343
+ if (paraText.includes('lvl="0"') && !paraText.includes('<a:buNone')) {
344
+ bulletIndices.push(i);
345
+ }
346
+ }
564
347
 
565
- new_para = apply_color_to_para(para, color)
566
- new_body = new_body[:start] + new_para + new_body[end:]
567
- offset += len(new_para) - len(para)
348
+ return { bulletIndices, paras };
349
+ }
568
350
 
569
- return xml[:match.start()] + before_body + new_body + after_body + xml[match.end():]
351
+ function colorContentPlaceholder(
352
+ xml: string,
353
+ defaultColor: string,
354
+ greyColor: string,
355
+ accentColor: string
356
+ ): string {
357
+ const pattern = /(<p:sp>.*?<p:ph idx="1"[^/]*\/?>.*?<p:txBody>)(.*?)(<\/p:txBody>.*?<\/p:sp>)/s;
358
+ const match = pattern.exec(xml);
359
+
360
+ if (!match) return xml;
361
+
362
+ const beforeBody = match[1];
363
+ const body = match[2];
364
+ const afterBody = match[3];
365
+
366
+ const { bulletIndices, paras } = getBulletParagraphs(body);
367
+ const isBuildup = isBuildupSlide(xml);
368
+
369
+ let newBody = body;
370
+ let offset = 0;
371
+
372
+ for (let i = 0; i < paras.length; i++) {
373
+ const para = paras[i];
374
+ const start = para.start + offset;
375
+ const end = para.end + offset;
376
+ const paraText = para.text;
377
+
378
+ let color: string;
379
+ if (bulletIndices.includes(i) && isBuildup) {
380
+ color = (i === bulletIndices[bulletIndices.length - 1]) ? accentColor : greyColor;
381
+ } else {
382
+ color = defaultColor;
383
+ }
570
384
 
385
+ const newPara = applyColorToPara(paraText, color);
386
+ newBody = newBody.slice(0, start) + newPara + newBody.slice(end);
387
+ offset += newPara.length - paraText.length;
388
+ }
571
389
 
572
- def color_title_placeholder(xml):
573
- """Apply title color to title placeholder (type='title' or type='ctrTitle')."""
390
+ return xml.slice(0, match.index) + beforeBody + newBody + afterBody + xml.slice(match.index + match[0].length);
391
+ }
574
392
 
575
- # Match title placeholders: type="title" or type="ctrTitle"
576
- pattern = r'(<p:sp>.*?<p:ph[^>]*type="(?:title|ctrTitle)"[^/]*/?>.*?<p:txBody>)(.*?)(</p:txBody>.*?</p:sp>)'
577
- match = re.search(pattern, xml, re.DOTALL)
393
+ function colorTitlePlaceholder(xml: string, titleColor: string): string {
394
+ const pattern = /(<p:sp>.*?<p:ph[^>]*type="(?:title|ctrTitle)"[^/]*\/?>.*?<p:txBody>)(.*?)(<\/p:txBody>.*?<\/p:sp>)/s;
395
+ const match = pattern.exec(xml);
578
396
 
579
- if not match:
580
- return xml
397
+ if (!match) return xml;
581
398
 
582
- before_body = match.group(1)
583
- body = match.group(2)
584
- after_body = match.group(3)
399
+ const beforeBody = match[1];
400
+ const body = match[2];
401
+ const afterBody = match[3];
585
402
 
586
- paras = list(re.finditer(r'<a:p>.*?</a:p>', body, re.DOTALL))
403
+ const paraRegex = /<a:p>.*?<\/a:p>/gs;
404
+ const paras: { start: number; end: number; text: string }[] = [];
405
+ let m: RegExpExecArray | null;
406
+ while ((m = paraRegex.exec(body)) !== null) {
407
+ paras.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
408
+ }
587
409
 
588
- new_body = body
589
- offset = 0
410
+ let newBody = body;
411
+ let offset = 0;
590
412
 
591
- for para_match in paras:
592
- start = para_match.start() + offset
593
- end = para_match.end() + offset
594
- para = para_match.group(0)
413
+ for (const para of paras) {
414
+ const start = para.start + offset;
415
+ const end = para.end + offset;
416
+ const newPara = applyColorToPara(para.text, titleColor);
417
+ newBody = newBody.slice(0, start) + newPara + newBody.slice(end);
418
+ offset += newPara.length - para.text.length;
419
+ }
595
420
 
596
- new_para = apply_color_to_para(para, TITLE)
597
- new_body = new_body[:start] + new_para + new_body[end:]
598
- offset += len(new_para) - len(para)
421
+ return xml.slice(0, match.index) + beforeBody + newBody + afterBody + xml.slice(match.index + match[0].length);
422
+ }
599
423
 
600
- return xml[:match.start()] + before_body + new_body + after_body + xml[match.end():]
424
+ /**
425
+ * Apply buildup greying to slides with buildup content.
426
+ * Greys out all bullet items except the last one, which gets the accent color.
427
+ * Only affects actual bullet items (not intro text with buNone).
428
+ */
429
+ export async function applyBuildupColors(pptxPath: string, config: BuildupConfig = {}): Promise<void> {
430
+ if (!existsSync(pptxPath)) return;
431
+ if (config.enabled === false) return;
601
432
 
602
- with zipfile.ZipFile(pptx_path, 'r') as zin:
603
- with zipfile.ZipFile(temp_path, 'w') as zout:
604
- for item in zin.infolist():
605
- content = zin.read(item.filename)
433
+ const defaultColor = (config.default || '608C32').replace(/^#/, '');
434
+ const titleColor = (config.title || defaultColor).replace(/^#/, '');
435
+ const greyColor = (config.grey || '888888').replace(/^#/, '');
436
+ const accentColor = (config.accent || defaultColor).replace(/^#/, '');
606
437
 
607
- if item.filename.startswith('ppt/slides/slide') and item.filename.endswith('.xml'):
608
- text = content.decode('utf-8')
609
- text = color_content_placeholder(text)
610
- text = color_title_placeholder(text)
611
- content = text.encode('utf-8')
438
+ const zip = new AdmZip(pptxPath);
612
439
 
613
- zout.writestr(item, content)
440
+ for (const entry of getSlideEntries(zip)) {
441
+ let text = entry.getData().toString('utf-8');
442
+ text = colorContentPlaceholder(text, defaultColor, greyColor, accentColor);
443
+ text = colorTitlePlaceholder(text, titleColor);
444
+ updateEntry(zip, entry.entryName, text);
445
+ }
446
+
447
+ zip.writeZip(pptxPath);
448
+ }
614
449
 
615
- os.replace(temp_path, pptx_path)
616
- `;
450
+ // =============================================================================
451
+ // Legacy Exports (signatures preserved for build.ts compatibility)
452
+ // =============================================================================
617
453
 
618
- writeFileSync(scriptPath, script);
619
- try {
620
- const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
621
- execSync(`${pythonCmd} "${scriptPath}" "${pptxPath}"`, { stdio: 'pipe' });
622
- } finally {
623
- try { unlinkSync(scriptPath); } catch { /* ignore */ }
454
+ export async function generatePptxTemplate(options: TemplateOptions): Promise<string | null> {
455
+ const { baseTemplate, outputPath } = options;
456
+ if (baseTemplate && existsSync(baseTemplate)) {
457
+ writeFileSync(outputPath, readFileSync(baseTemplate));
458
+ return outputPath;
624
459
  }
460
+ return null;
461
+ }
462
+
463
+ export function templateNeedsRegeneration(templatePath: string, mediaDir: string, baseTemplate: string): boolean {
464
+ return false;
465
+ }
466
+
467
+ export async function injectMediaIntoPptx(pptxPath: string, mediaDir: string): Promise<void> {
468
+ return injectLogosIntoSlides(pptxPath, mediaDir);
625
469
  }