Skip to content

Commit b261dba

Browse files
yotsudaclaude
andcommitted
Improve YouTube embed handling and slide export quality
- Download external images for pptx export (SlideKit only supports local files) - YouTube thumbnails get their own slide via --- separators in pptx - Add --slide-level=3 to Pandoc so ### headings also split slides - SlideKit: split slides on ### headings, support image+text coexistence with two-column layout, preserve plain text in headingless slides Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9a040b6 commit b261dba

3 files changed

Lines changed: 121 additions & 23 deletions

File tree

MarkdownPointer/Services/ExportService.cs

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.IO;
2+
using System.Net.Http;
3+
using System.Text.RegularExpressions;
24
using Microsoft.Web.WebView2.Wpf;
35

46
namespace MarkdownPointer.Services
@@ -104,14 +106,38 @@ await webView.CoreWebView2.ExecuteScriptAsync(
104106
}
105107

106108
// YouTube iframes → thumbnail images
107-
var ytReplaced = HtmlGenerator.YouTubeIframePattern.Replace(mdContent, match =>
109+
var ytMatches = HtmlGenerator.YouTubeIframePattern.Matches(mdContent);
110+
if (ytMatches.Count > 0)
108111
{
109-
var videoId = match.Groups[1].Value;
110-
return $"[![YouTube video](https://img.youtube.com/vi/{videoId}/maxresdefault.jpg)](https://www.youtube.com/watch?v={videoId})";
111-
});
112-
if (ytReplaced != mdContent)
113-
{
114-
mdContent = ytReplaced;
112+
if (tempDir == null)
113+
{
114+
tempDir = Path.Combine(Path.GetTempPath(), $"mdp_export_{Guid.NewGuid():N}");
115+
Directory.CreateDirectory(tempDir);
116+
}
117+
118+
foreach (System.Text.RegularExpressions.Match ytMatch in ytMatches)
119+
{
120+
var videoId = ytMatch.Groups[1].Value;
121+
var thumbUrl = $"https://img.youtube.com/vi/{videoId}/maxresdefault.jpg";
122+
var localPath = Path.Combine(tempDir, $"yt_{videoId}.jpg");
123+
124+
// Download thumbnail for pptx (SlideKit only supports local files)
125+
try
126+
{
127+
using var http = new HttpClient();
128+
var bytes = await http.GetByteArrayAsync(thumbUrl);
129+
await File.WriteAllBytesAsync(localPath, bytes);
130+
}
131+
catch
132+
{
133+
continue;
134+
}
135+
136+
var imgMarkdown = ext == ".pptx"
137+
? $"\n---\n![YouTube video]({localPath})\n---\n"
138+
: $"![YouTube video]({localPath})";
139+
mdContent = mdContent.Replace(ytMatch.Value, imgMarkdown);
140+
}
115141
modified = true;
116142
}
117143

@@ -132,6 +158,12 @@ await webView.CoreWebView2.ExecuteScriptAsync(
132158
}
133159
}
134160

161+
// Download external images to local files for pptx (SlideKit only supports local files)
162+
if (ext == ".pptx")
163+
{
164+
(mdContent, modified, tempDir) = await DownloadExternalImagesAsync(mdContent, modified, tempDir);
165+
}
166+
135167
if (modified)
136168
{
137169
if (tempDir == null)
@@ -172,5 +204,46 @@ public static void CleanupTempDir(string? tempDir)
172204
}
173205
}
174206

207+
private static readonly Regex MarkdownImagePattern = new(
208+
@"!\[[^\]]*\]\((https?://[^)\s]+)\)",
209+
RegexOptions.IgnoreCase | RegexOptions.Compiled);
210+
211+
/// <summary>
212+
/// Downloads external (http/https) images to local temp files and replaces URLs in markdown.
213+
/// </summary>
214+
private static async Task<(string Content, bool Modified, string? TempDir)> DownloadExternalImagesAsync(
215+
string mdContent, bool modified, string? tempDir)
216+
{
217+
var matches = MarkdownImagePattern.Matches(mdContent);
218+
if (matches.Count == 0) return (mdContent, modified, tempDir);
219+
220+
if (tempDir == null)
221+
{
222+
tempDir = Path.Combine(Path.GetTempPath(), $"mdp_export_{Guid.NewGuid():N}");
223+
Directory.CreateDirectory(tempDir);
224+
}
225+
226+
using var http = new HttpClient();
227+
int index = 0;
228+
foreach (Match match in matches)
229+
{
230+
var url = match.Groups[1].Value;
231+
var uri = new Uri(url);
232+
var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant();
233+
if (string.IsNullOrEmpty(ext) || ext.Length > 5) ext = ".png";
234+
var localPath = Path.Combine(tempDir, $"img_{index++}{ext}");
235+
236+
try
237+
{
238+
var bytes = await http.GetByteArrayAsync(url);
239+
await File.WriteAllBytesAsync(localPath, bytes);
240+
mdContent = mdContent.Replace(url, localPath);
241+
modified = true;
242+
}
243+
catch { }
244+
}
245+
246+
return (mdContent, modified, tempDir);
247+
}
175248
}
176249
}

MarkdownPointer/Services/SlideService.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public static class SlideService
3434
// Pandoc uses data-src for lazy loading in reveal.js; convert to src for WebView2
3535
html = html.Replace("data-src=\"", "src=\"");
3636

37+
// Replace YouTube iframes with clickable thumbnails (file:// origin blocks embeds)
38+
html = HtmlGenerator.ReplaceYouTubeIframes(html);
39+
3740
// Inline local images as base64 (NavigateToString blocks file:// access)
3841
var baseDir = Path.GetDirectoryName(markdownPath)!;
3942
html = HtmlGenerator.InlineLocalImages(html, baseDir);
@@ -80,7 +83,7 @@ public static class SlideService
8083
var psi = new ProcessStartInfo
8184
{
8285
FileName = "pandoc",
83-
Arguments = $"-f markdown-implicit_figures -t revealjs -s " +
86+
Arguments = $"-f markdown-implicit_figures -t revealjs -s --slide-level=3 " +
8487
$"--variable revealjs-url=https://cdn.jsdelivr.net/npm/reveal.js@5.2.1 " +
8588
$"--variable theme={theme} " +
8689
$"-o \"{outputPath}\" \"{markdownPath}\"",

SlideKit/Parsing/MarkdownToDeckConverter.cs

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,8 @@ private Slide BuildSlide(string slideText, string slideType)
265265
continue;
266266
}
267267

268-
// Plain text (subtitle for title slides)
269-
if (!string.IsNullOrWhiteSpace(trimmed) && title != null)
268+
// Plain text
269+
if (!string.IsNullOrWhiteSpace(trimmed))
270270
subtitle = (subtitle == null ? "" : subtitle + " ") + trimmed;
271271
}
272272

@@ -348,22 +348,40 @@ private Slide BuildContentSlide(string? title, string? subtitle, List<string> bu
348348
long cY = title != null ? ContentY : 40 * Emu;
349349
long cH = SlideH - cY - 30 * Emu;
350350

351+
// Determine if we have both image and text content
352+
bool hasImage = imagePath != null;
353+
bool hasText = bullets.Count > 0 || !string.IsNullOrWhiteSpace(subtitle);
354+
bool hasTable = tableHeaders.Count > 0;
355+
bool hasCode = codeLines.Count > 0;
356+
357+
// When image + text coexist: left text, right image
358+
long textW = 864 * Emu;
359+
long imgX = ContentLeft;
360+
long imgW = 852 * Emu;
361+
if (hasImage && (hasText || hasTable || hasCode))
362+
{
363+
textW = 420 * Emu;
364+
imgX = ContentLeft + 440 * Emu;
365+
imgW = 412 * Emu;
366+
}
367+
351368
// Image
352-
if (imagePath != null)
369+
if (hasImage)
353370
{
354371
slide.Shapes.Add(new Shape
355372
{
356-
Type = "image", X = ContentLeft, Y = cY + 10 * Emu,
357-
Width = 852 * Emu, Height = cH - 10 * Emu,
373+
Type = "image", X = imgX, Y = cY + 10 * Emu,
374+
Width = imgW, Height = cH - 10 * Emu,
358375
Source = imagePath
359376
});
360377
}
378+
361379
// Table
362-
else if (tableHeaders.Count > 0)
380+
if (hasTable)
363381
{
364382
slide.Shapes.Add(new Shape
365383
{
366-
Type = "table", X = ContentLeft, Y = cY + 10 * Emu, Width = 852 * Emu,
384+
Type = "table", X = ContentLeft, Y = cY + 10 * Emu, Width = textW,
367385
Height = Math.Min(cH, 50 * Emu * (tableRows.Count + 1)),
368386
FontSize = TableFontSize,
369387
Headers = tableHeaders, Rows = tableRows,
@@ -372,29 +390,33 @@ private Slide BuildContentSlide(string? title, string? subtitle, List<string> bu
372390
});
373391
}
374392
// Code block
375-
else if (codeLines.Count > 0)
393+
else if (hasCode)
376394
{
377395
long codeH = Math.Min(cH, 30 * Emu * codeLines.Count + 30 * Emu);
378396
slide.Shapes.Add(new Shape
379397
{
380398
Type = "rectangle", X = ContentLeft, Y = cY + 10 * Emu,
381-
Width = 852 * Emu, Height = codeH, Fill = "F5F5F5"
399+
Width = textW, Height = codeH, Fill = "F5F5F5"
382400
});
383401
slide.Shapes.Add(new Shape
384402
{
385403
Type = "textbox", X = ContentLeft + 24 * Emu, Y = cY + 25 * Emu,
386-
Width = 804 * Emu, Height = codeH - 30 * Emu,
404+
Width = textW - 48 * Emu, Height = codeH - 30 * Emu,
387405
Text = string.Join("\n", codeLines), FontSize = 2200, Color = "333333"
388406
});
389407
}
390-
// Bullets
408+
// Bullets (with optional subtitle as leading text)
391409
else if (bullets.Count > 0)
392410
{
411+
var allBullets = new List<string>();
412+
if (!string.IsNullOrWhiteSpace(subtitle))
413+
allBullets.Add(subtitle);
414+
allBullets.AddRange(bullets);
393415
slide.Shapes.Add(new Shape
394416
{
395417
Type = "textbox", X = ContentLeft, Y = cY,
396-
Width = 864 * Emu, Height = cH,
397-
Bullets = bullets, FontSize = ContentFontSize
418+
Width = textW, Height = cH,
419+
Bullets = allBullets, FontSize = ContentFontSize
398420
});
399421
}
400422
// Plain text
@@ -403,7 +425,7 @@ private Slide BuildContentSlide(string? title, string? subtitle, List<string> bu
403425
slide.Shapes.Add(new Shape
404426
{
405427
Type = "textbox", X = ContentLeft, Y = cY,
406-
Width = 864 * Emu, Height = cH,
428+
Width = textW, Height = cH,
407429
Text = subtitle, FontSize = ContentFontSize
408430
});
409431
}
@@ -414,7 +436,7 @@ private Slide BuildContentSlide(string? title, string? subtitle, List<string> bu
414436
[GeneratedRegex(@"^-{3,}\s*$")]
415437
private static partial Regex HrPattern();
416438

417-
[GeneratedRegex(@"^#{1,2}\s+")]
439+
[GeneratedRegex(@"^#{1,3}\s+")]
418440
private static partial Regex SlideHeadingPattern();
419441

420442
[GeneratedRegex(@"<!--\s*slide:\s*(.+?)\s*-->")]

0 commit comments

Comments
 (0)