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.
- package/README.md +25 -1
- package/bin/rev.js +5 -12
- package/dist/bin/rev.js +2 -2
- 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 +8 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +195 -3
- 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/crossref.d.ts +15 -0
- package/dist/lib/crossref.d.ts.map +1 -1
- package/dist/lib/crossref.js +54 -1
- package/dist/lib/crossref.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 +37 -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 +21 -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 +218 -4
- package/lib/commands/build.ts +25 -7
- package/lib/commands/response.ts +55 -2
- package/lib/commands/sections.ts +31 -9
- package/lib/crossref.ts +62 -1
- package/lib/csl.ts +191 -0
- package/lib/import.ts +21 -7
- package/lib/journals.ts +39 -1
- package/lib/plugins.ts +35 -1
- package/lib/pptx-template.ts +346 -502
- package/lib/schema.ts +4 -0
- package/lib/types.ts +22 -1
- package/lib/word.ts +253 -38
- package/package.json +37 -38
- package/scripts/postbuild.js +28 -0
- package/skill/REFERENCE.md +1 -1
- package/skill/SKILL.md +1 -1
- package/lib/apply-buildup-colors.py +0 -88
|
@@ -1,613 +1,357 @@
|
|
|
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
|
-
import { existsSync, readFileSync, writeFileSync
|
|
8
|
-
import { join
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
+
updateEntry(zip, entry.entryName, text);
|
|
65
57
|
}
|
|
58
|
+
zip.writeZip(pptxPath);
|
|
66
59
|
}
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// 2. Apply Centering
|
|
62
|
+
// =============================================================================
|
|
67
63
|
/**
|
|
68
|
-
*
|
|
64
|
+
* Apply horizontal centering to slides that have the .center class.
|
|
69
65
|
*/
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
* Only adds
|
|
92
|
-
* Title
|
|
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
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
return injectLogosIntoSlides(pptxPath, mediaDir);
|
|
243
|
+
function isBuildupSlide(xml) {
|
|
244
|
+
return xml.includes('animEffect') || xml.includes('<a:bldLst>');
|
|
307
245
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|