Release 2.8.1: pptx-wizard slide editor + fixes
- Fix pptx-wizard slide editor modal opening off-screen (header clipped) when a saved drag position is restored on a fresh page load: clear the CSS centering transform and clamp coordinates into the viewport. - Add pptx-wizard.html and python-runner.html web UIs. - Bump version to 2.8.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+68
-9
@@ -179,7 +179,7 @@ async function generateWithPython(spec: PresentationSpec, workspacePath: string)
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
}, (err, stdout, stderr) => {
|
||||
if (stderr) {
|
||||
console.error(`[pptx] Python stderr: ${stderr.slice(0, 3000)}`);
|
||||
console.debug(`[pptx] Python stderr: ${stderr.slice(0, 3000)}`);
|
||||
}
|
||||
if (err) {
|
||||
console.error(`[pptx] Python process failed: ${err.message}`);
|
||||
@@ -265,6 +265,10 @@ interface SlideSpec {
|
||||
right_body?: string;
|
||||
// Timeline slide
|
||||
events?: { year?: string; label?: string; text?: string; description?: string }[];
|
||||
// Image effects (Pillow-based post-processing)
|
||||
img_filter?: string;
|
||||
img_blur_radius?: number;
|
||||
img_corner_radius?: number;
|
||||
// Edit mode: 1-based slide number to replace (used with edit_presentation)
|
||||
replace_index?: number;
|
||||
}
|
||||
@@ -305,6 +309,11 @@ async function downloadImageByUrl(url: string, dest: string): Promise<boolean> {
|
||||
else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) actualFmt = 'gif';
|
||||
else if (buffer.length > 8 && buffer.slice(4, 12).toString('ascii') === 'ftypavif') actualFmt = 'avif';
|
||||
else if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) actualFmt = 'webp';
|
||||
// Reject HTML responses (e.g. PMC figure page URLs like .../bin/Fig1_HTM)
|
||||
if (actualFmt === 'unknown' && (ct.includes('text/html') || buffer.slice(0, 15).toString('ascii').toLowerCase().includes('<!doc'))) {
|
||||
console.log(`[pptx] downloadImageByUrl: HTML response rejected for ${url.slice(0, 80)}`);
|
||||
return false;
|
||||
}
|
||||
console.log(`[pptx] downloadImageByUrl: ${buffer.length} bytes, ct="${ct}", magic=${actualFmt}, dest=${path.basename(dest)}`);
|
||||
fs.writeFileSync(dest, buffer);
|
||||
return true;
|
||||
@@ -578,10 +587,12 @@ interface FontSizes {
|
||||
interface PresentationSpec {
|
||||
filename?: string;
|
||||
title?: string;
|
||||
project?: string;
|
||||
theme?: string;
|
||||
template?: string;
|
||||
default_skin?: string;
|
||||
font_sizes?: FontSizes;
|
||||
source_files?: string[];
|
||||
slides: SlideSpec[];
|
||||
}
|
||||
|
||||
@@ -591,7 +602,7 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
name: 'create_presentation',
|
||||
description: 'Generate a PowerPoint (.pptx) file with slides. Slide types: "title", "content", "section", "image", "table", "chart", "timeline", "blank". Content layouts: "split-right" (default), "split-left", "text", "fullscreen", "compare". Use image_search to auto-find images. Put ALL slides in ONE call — never split. NEVER write Python scripts to create PPTX.',
|
||||
schema: {
|
||||
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',
|
||||
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, img_filter, img_blur_radius, img_corner_radius, background, template }] }',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
@@ -602,9 +613,11 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Output filename (default: presentation.pptx)' },
|
||||
title: { type: 'string', description: 'Presentation title — used to name the project folder' },
|
||||
project: { type: 'string', description: 'Project folder name (alternative to title — use this to save into an existing folder)' },
|
||||
template: { type: 'string', description: `Template: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
theme: { type: 'string', description: 'Override: "dark" or "light"' },
|
||||
font_sizes: { type: 'object', description: 'Override default font sizes (pt). Keys: title(40), subtitle(20), slide_title(28), body(18), bullets(18), section(36), image_title(24)' },
|
||||
source_files: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative paths of source files (PDFs, extracted image dirs) to copy into the project folder. E.g. ["pubmed/PMC123.pdf", "uploads/PMC123_images"]' },
|
||||
slides: {
|
||||
type: 'array',
|
||||
description: 'Array of slide specifications',
|
||||
@@ -623,6 +636,9 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' },
|
||||
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
|
||||
image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' },
|
||||
img_filter: { type: 'string', description: 'Image effect filter: "grayscale" (흑백), "blur" (블러), "contrast" (대비 강화), "bright" (밝게), "round" (둥근 모서리), or "none". Applied to the slide image via Pillow before embedding.' },
|
||||
img_blur_radius: { type: 'number', description: 'Blur radius when img_filter="blur" (default: 5, range: 1-20)' },
|
||||
img_corner_radius: { type: 'number', description: 'Corner radius % when img_filter="round" (default: 15, range: 5-50)' },
|
||||
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
|
||||
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
notes: { type: 'string', description: 'Speaker notes' },
|
||||
@@ -680,7 +696,7 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
}
|
||||
|
||||
// ── Phase 1: Create project directory ──
|
||||
const projectSlug = (spec.title || 'presentation')
|
||||
const projectSlug = (spec.title || spec.project || 'presentation')
|
||||
.replace(/\.pptx$/i, '')
|
||||
.replace(/[^a-zA-Z0-9가-힣_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
@@ -722,14 +738,21 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
}
|
||||
}
|
||||
if (slide.image_url && !slide.image_path) {
|
||||
const url = slide.image_url;
|
||||
let url = slide.image_url;
|
||||
// /api/files/<relative-path> is a server API alias for workspace/<relative-path>
|
||||
if (url.startsWith('/api/files/')) {
|
||||
url = path.join(workspacePath, decodeURIComponent(url.slice('/api/files/'.length)));
|
||||
}
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
// Local workspace-relative path — copy to project folder
|
||||
const srcPath = path.isAbsolute(url) ? url : path.join(workspacePath, url);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
const ext = path.extname(srcPath) || '.png';
|
||||
// Try original path, then spaces→underscores variant
|
||||
const srcPathAlt = srcPath.replace(/ /g, '_');
|
||||
const resolvedSrc = fs.existsSync(srcPath) ? srcPath : fs.existsSync(srcPathAlt) ? srcPathAlt : null;
|
||||
if (resolvedSrc) {
|
||||
const ext = path.extname(resolvedSrc) || '.png';
|
||||
const fname = `slide${i + 1}_image${ext}`;
|
||||
fs.copyFileSync(srcPath, path.join(projectDir, fname));
|
||||
fs.copyFileSync(resolvedSrc, path.join(projectDir, fname));
|
||||
slide.image_path = fname;
|
||||
console.log(`[pptx] slide ${i + 1} image_url local copy -> ${fname}`);
|
||||
} else {
|
||||
@@ -826,6 +849,39 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2.5: Copy source files (PDFs, image dirs) into project folder ──
|
||||
const sourceFiles: string[] = Array.isArray(spec.source_files) ? spec.source_files : [];
|
||||
for (const srcRel of sourceFiles) {
|
||||
const srcAbs = path.isAbsolute(srcRel) ? srcRel : path.join(workspacePath, srcRel);
|
||||
if (!fs.existsSync(srcAbs)) {
|
||||
console.log(`[pptx] source_files: not found, skipping: ${srcRel}`);
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(srcAbs);
|
||||
if (stat.isDirectory()) {
|
||||
// Copy entire directory (e.g., uploads/PMC123_images → projectDir/PMC123_images)
|
||||
const destDir = path.join(projectDir, path.basename(srcAbs));
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
for (const f of fs.readdirSync(srcAbs)) {
|
||||
try {
|
||||
fs.copyFileSync(path.join(srcAbs, f), path.join(destDir, f));
|
||||
} catch (e) {
|
||||
console.log(`[pptx] source_files: could not copy ${f}: ${e}`);
|
||||
}
|
||||
}
|
||||
console.log(`[pptx] source_files: copied dir ${path.basename(srcAbs)} -> ${destDir}`);
|
||||
} else {
|
||||
// Copy single file (e.g., pubmed/PMC123.pdf → projectDir/PMC123.pdf)
|
||||
const dest = path.join(projectDir, path.basename(srcAbs));
|
||||
try {
|
||||
fs.copyFileSync(srcAbs, dest);
|
||||
console.log(`[pptx] source_files: copied ${path.basename(srcAbs)} -> ${dest}`);
|
||||
} catch (e) {
|
||||
console.log(`[pptx] source_files: could not copy ${srcAbs}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: Generate PPTX ──
|
||||
return await generateWithPython(spec, workspacePath);
|
||||
},
|
||||
@@ -833,10 +889,10 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
|
||||
export const editPptxTool: import('./registry.js').Tool = {
|
||||
name: 'edit_presentation',
|
||||
description: 'Append slides to an existing .pptx file. Supports all slide types: title, content, section, image, table, chart, timeline. Use image_search to auto-find images. Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
|
||||
description: 'Append slides to an existing .pptx file. Supports all slide types: title, content, section, image, table, chart, timeline. Use image_search to auto-find images. Supports img_filter for image effects (grayscale, blur, contrast, bright, round). Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
|
||||
schema: {
|
||||
path: 'Path to existing .pptx file (relative to workspace or absolute)',
|
||||
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',
|
||||
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, img_filter, img_blur_radius, img_corner_radius, background, template }] }',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
@@ -865,6 +921,9 @@ export const editPptxTool: import('./registry.js').Tool = {
|
||||
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' },
|
||||
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
|
||||
image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' },
|
||||
img_filter: { type: 'string', description: 'Image effect filter: "grayscale" (흑백), "blur" (블러), "contrast" (대비 강화), "bright" (밝게), "round" (둥근 모서리), or "none". Applied to the slide image via Pillow before embedding.' },
|
||||
img_blur_radius: { type: 'number', description: 'Blur radius when img_filter="blur" (default: 5, range: 1-20)' },
|
||||
img_corner_radius: { type: 'number', description: 'Corner radius % when img_filter="round" (default: 15, range: 5-50)' },
|
||||
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
|
||||
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
notes: { type: 'string', description: 'Speaker notes' },
|
||||
|
||||
+42
-12
@@ -348,8 +348,7 @@ export const pubmedFulltextTool = {
|
||||
};
|
||||
}
|
||||
|
||||
// Download PDF
|
||||
const config = getConfig().getConfig();
|
||||
// Download PDF — try Unpaywall URL first, then Europe PMC as fallback
|
||||
const workspaceDir = getWorkspacePath(args);
|
||||
const pdfDir = path.join(workspaceDir, 'pubmed');
|
||||
fs.mkdirSync(pdfDir, { recursive: true });
|
||||
@@ -358,16 +357,47 @@ export const pubmedFulltextTool = {
|
||||
? path.join(workspaceDir, args.save_path)
|
||||
: path.join(pdfDir, `${pmcid}.pdf`);
|
||||
|
||||
const pdfRes = await fetch(pdfUrl, {
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SmallClaw/1.0; PubMed research bot)' },
|
||||
});
|
||||
if (!pdfRes.ok) {
|
||||
return { success: false, error: `PDF download failed: HTTP ${pdfRes.status} from ${pdfUrl}` };
|
||||
}
|
||||
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
|
||||
fs.writeFileSync(pdfDest, pdfBuf);
|
||||
const europePmcUrl = `https://europepmc.org/api/getPdf?pmcid=${pmcid}`;
|
||||
const urlsToTry = [pdfUrl, europePmcUrl].filter(Boolean);
|
||||
|
||||
let pdfBuf: Buffer | null = null;
|
||||
let usedUrl = '';
|
||||
for (const tryUrl of urlsToTry) {
|
||||
try {
|
||||
const res = await fetch(tryUrl, {
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/pdf,*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://europepmc.org/',
|
||||
},
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// Reject HTML responses (PMC sometimes returns HTML instead of PDF)
|
||||
if (!buf.slice(0, 5).toString('ascii').startsWith('%PDF')) {
|
||||
console.log(`[pubmed] ${tryUrl} returned non-PDF content (HTML?), trying next`);
|
||||
continue;
|
||||
}
|
||||
pdfBuf = buf;
|
||||
usedUrl = tryUrl;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!pdfBuf) {
|
||||
return {
|
||||
success: false,
|
||||
error: [
|
||||
`PDF download failed for ${pmcid}: all sources returned non-PDF content.`,
|
||||
`Tried: ${urlsToTry.join(', ')}`,
|
||||
`Try format "text" to get the plain-text version instead.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
fs.writeFileSync(pdfDest, pdfBuf);
|
||||
const sizeKb = (pdfBuf.length / 1024).toFixed(1);
|
||||
const relPath = path.relative(workspaceDir, pdfDest);
|
||||
return {
|
||||
@@ -378,7 +408,7 @@ export const pubmedFulltextTool = {
|
||||
`Saved to: ${relPath} (${sizeKb} KB)`,
|
||||
`File link: /api/files/${relPath}`,
|
||||
].filter(Boolean).join('\n'),
|
||||
data: { pmcid, doi, pdf_url: pdfUrl, save_path: pdfDest, size_kb: Number(sizeKb) },
|
||||
data: { pmcid, doi, pdf_url: usedUrl, save_path: pdfDest, size_kb: Number(sizeKb) },
|
||||
};
|
||||
}
|
||||
} catch (oaErr: any) {
|
||||
|
||||
@@ -81,7 +81,7 @@ export const openalexSearchTool = {
|
||||
'per-page': String(limit),
|
||||
sort: sortKey,
|
||||
filter: filters.join(','),
|
||||
select: 'id,doi,display_name,publication_year,cited_by_count,open_access,authorships,abstract_inverted_index,primary_location',
|
||||
select: 'id,doi,display_name,publication_year,cited_by_count,open_access,best_oa_location,authorships,abstract_inverted_index,primary_location',
|
||||
mailto: POLITE_EMAIL,
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ export const openalexSearchTool = {
|
||||
.join(', ') + ((r.authorships || []).length > 6 ? ' et al.' : '');
|
||||
const citations = r.cited_by_count ?? 0;
|
||||
const isOa = r.open_access?.is_oa ?? false;
|
||||
const oaUrl = r.open_access?.oa_url || '';
|
||||
const oaUrl = r.best_oa_location?.pdf_url || r.open_access?.oa_url || '';
|
||||
const abstract = reconstructAbstract(r.abstract_inverted_index).slice(0, 400);
|
||||
|
||||
lines.push(`[${i + 1}] ${title}`);
|
||||
@@ -132,7 +132,8 @@ export const openalexSearchTool = {
|
||||
doi: r.doi ? String(r.doi).replace('https://doi.org/', '') : '',
|
||||
citations: r.cited_by_count,
|
||||
is_oa: r.open_access?.is_oa,
|
||||
oa_url: r.open_access?.oa_url,
|
||||
oa_url: r.best_oa_location?.pdf_url || null,
|
||||
abstract: reconstructAbstract(r.abstract_inverted_index).slice(0, 2000),
|
||||
authors: (r.authorships || []).slice(0, 6).map((a: any) => a?.author?.display_name).filter(Boolean),
|
||||
})) },
|
||||
};
|
||||
@@ -243,6 +244,7 @@ export const semanticSearchTool = {
|
||||
citations: p.citationCount,
|
||||
inf_citations: p.influentialCitationCount,
|
||||
oa_pdf: p.openAccessPdf?.url,
|
||||
abstract: String(p.abstract || '').slice(0, 2000),
|
||||
authors: (p.authors || []).slice(0, 6).map((a: any) => a.name),
|
||||
})) },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user