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.
- package/.gitattributes +1 -0
- package/README.md +25 -1
- package/dist/lib/annotations.d.ts.map +1 -1
- package/dist/lib/annotations.js +6 -0
- package/dist/lib/annotations.js.map +1 -1
- package/dist/lib/build.d.ts +6 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +67 -1
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +26 -7
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/response.d.ts.map +1 -1
- package/dist/lib/commands/response.js +50 -2
- package/dist/lib/commands/response.js.map +1 -1
- package/dist/lib/commands/sections.d.ts.map +1 -1
- package/dist/lib/commands/sections.js +28 -9
- package/dist/lib/commands/sections.js.map +1 -1
- package/dist/lib/csl.d.ts +38 -0
- package/dist/lib/csl.d.ts.map +1 -0
- package/dist/lib/csl.js +170 -0
- package/dist/lib/csl.js.map +1 -0
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/import.js +20 -7
- package/dist/lib/import.js.map +1 -1
- package/dist/lib/journals.d.ts.map +1 -1
- package/dist/lib/journals.js +24 -0
- package/dist/lib/journals.js.map +1 -1
- package/dist/lib/plugins.d.ts +11 -0
- package/dist/lib/plugins.d.ts.map +1 -1
- package/dist/lib/plugins.js +21 -1
- package/dist/lib/plugins.js.map +1 -1
- package/dist/lib/pptx-template.d.ts +17 -22
- package/dist/lib/pptx-template.d.ts.map +1 -1
- package/dist/lib/pptx-template.js +296 -552
- package/dist/lib/pptx-template.js.map +1 -1
- package/dist/lib/schema.d.ts.map +1 -1
- package/dist/lib/schema.js +4 -0
- package/dist/lib/schema.js.map +1 -1
- package/dist/lib/types.d.ts +19 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/word.d.ts +24 -11
- package/dist/lib/word.d.ts.map +1 -1
- package/dist/lib/word.js +233 -32
- package/dist/lib/word.js.map +1 -1
- package/lib/annotations.ts +8 -0
- package/lib/build.ts +75 -2
- package/lib/commands/build.ts +25 -7
- package/lib/commands/response.ts +55 -2
- package/lib/commands/sections.ts +31 -9
- package/lib/csl.ts +191 -0
- package/lib/import.ts +21 -7
- package/lib/journals.ts +25 -1
- package/lib/plugins.ts +35 -1
- package/lib/pptx-template.ts +346 -502
- package/lib/schema.ts +4 -0
- package/lib/types.ts +20 -1
- package/lib/word.ts +253 -38
- package/package.json +1 -2
- package/lib/apply-buildup-colors.py +0 -88
package/lib/pptx-template.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PPTX post-processing
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Pure TypeScript implementation using AdmZip for in-memory ZIP/PPTX manipulation.
|
|
5
|
+
* No Python dependency required.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync,
|
|
9
|
-
import { join
|
|
10
|
-
import
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
339
|
-
const
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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
|
|
384
|
-
const
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
centered_indices = json.loads('${indicesJson}')
|
|
389
|
-
temp_path = pptx_path + '.tmp'
|
|
113
|
+
let text = entry.getData().toString('utf-8');
|
|
390
114
|
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
129
|
+
if (attrs.endsWith('/')) {
|
|
130
|
+
attrs = attrs.slice(0, -1).trimEnd();
|
|
131
|
+
isSelfClosing = true;
|
|
132
|
+
}
|
|
398
133
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
134
|
+
if (!attrs.includes('algn=')) {
|
|
135
|
+
attrs += ' algn="ctr"';
|
|
136
|
+
} else {
|
|
137
|
+
attrs = attrs.replace(/algn="[^"]*"/, 'algn="ctr"');
|
|
138
|
+
}
|
|
402
139
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
147
|
+
return shape;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
updateEntry(zip, entry.entryName, text);
|
|
151
|
+
}
|
|
420
152
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
else:
|
|
424
|
-
attrs = re.sub(r'algn="[^"]*"', 'algn="ctr"', attrs)
|
|
153
|
+
zip.writeZip(pptxPath);
|
|
154
|
+
}
|
|
425
155
|
|
|
426
|
-
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// 3. Inject Slide Numbers
|
|
158
|
+
// =============================================================================
|
|
427
159
|
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
178
|
+
const zip = new AdmZip(pptxPath);
|
|
179
|
+
const slides = getSlideEntries(zip);
|
|
437
180
|
|
|
438
|
-
|
|
181
|
+
// Pass 1: identify content slides and assign sequential numbers
|
|
182
|
+
const slideNumbers = new Map<string, number>();
|
|
183
|
+
let contentNum = 1;
|
|
439
184
|
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
|
473
|
-
const script = `import zipfile
|
|
474
|
-
import sys
|
|
475
|
-
import re
|
|
476
|
-
import os
|
|
225
|
+
const zip = new AdmZip(pptxPath);
|
|
477
226
|
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
268
|
+
if (newRels.length > 0) {
|
|
269
|
+
relsXml = relsXml.replace('</Relationships>', newRels.join('') + '</Relationships>');
|
|
270
|
+
updateEntry(zip, relsName, relsXml);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
510
273
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
304
|
+
zip.writeZip(pptxPath);
|
|
305
|
+
}
|
|
528
306
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
307
|
+
// =============================================================================
|
|
308
|
+
// 5. Apply Buildup Colors
|
|
309
|
+
// =============================================================================
|
|
532
310
|
|
|
533
|
-
|
|
534
|
-
|
|
311
|
+
function applyColorToPara(para: string, color: string): string {
|
|
312
|
+
const fill = `<a:solidFill><a:srgbClr val="${color}"/></a:solidFill>`;
|
|
535
313
|
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
545
324
|
|
|
546
|
-
|
|
547
|
-
|
|
325
|
+
function isBuildupSlide(xml: string): boolean {
|
|
326
|
+
return xml.includes('animEffect') || xml.includes('<a:bldLst>');
|
|
327
|
+
}
|
|
548
328
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
offset += len(new_para) - len(para)
|
|
348
|
+
return { bulletIndices, paras };
|
|
349
|
+
}
|
|
568
350
|
|
|
569
|
-
|
|
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
|
-
|
|
573
|
-
|
|
390
|
+
return xml.slice(0, match.index) + beforeBody + newBody + afterBody + xml.slice(match.index + match[0].length);
|
|
391
|
+
}
|
|
574
392
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
580
|
-
return xml
|
|
397
|
+
if (!match) return xml;
|
|
581
398
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
399
|
+
const beforeBody = match[1];
|
|
400
|
+
const body = match[2];
|
|
401
|
+
const afterBody = match[3];
|
|
585
402
|
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
410
|
+
let newBody = body;
|
|
411
|
+
let offset = 0;
|
|
590
412
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
450
|
+
// =============================================================================
|
|
451
|
+
// Legacy Exports (signatures preserved for build.ts compatibility)
|
|
452
|
+
// =============================================================================
|
|
617
453
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
}
|