docrev 0.9.0 → 0.9.4

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