diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs index 5033beee..1dbfef30 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs @@ -351,7 +351,10 @@ public partial class PowerPointHandler // Hyperlink on shape if (properties.TryGetValue("link", out var linkVal)) - ApplyShapeHyperlink(slidePart, newShape, linkVal); + { + var tooltipVal = properties.GetValueOrDefault("tooltip"); + ApplyShapeHyperlink(slidePart, newShape, linkVal, tooltipVal); + } // lineDash, effects, 3D, flip — delegate to SetRunOrShapeProperties var effectKeys = new HashSet(StringComparer.OrdinalIgnoreCase) diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs index 8d056326..641b40b0 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs @@ -25,6 +25,41 @@ public partial class PowerPointHandler var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; var xfrm = shape.ShapeProperties?.Transform2D; + // Shape-level hyperlink → wrap rendered shape
in for clickability in HTML preview. + // Only external URLs are wrapped; internal slide-jump links (ppaction://hlinksldjump) are + // skipped because there is no corresponding external href in this static HTML context. + string? shapeHrefUrl = null; + string? shapeHrefTooltip = null; + { + var nvHlink = shape.NonVisualShapeProperties?.NonVisualDrawingProperties + ?.GetFirstChild(); + if (nvHlink != null) + { + shapeHrefTooltip = nvHlink.Tooltip?.Value; + var action = nvHlink.Action?.Value; + var hlId = nvHlink.Id?.Value; + // Skip if this is a slide-jump action (no external URL target) + if (string.IsNullOrEmpty(action) || !action.Contains("hlink")) + { + // Plain external: no action + r:id → look up external relationship + if (!string.IsNullOrEmpty(hlId)) + { + try + { + var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == hlId); + if (rel?.Uri != null) shapeHrefUrl = rel.Uri.ToString(); + } + catch { } + } + } + else if (action.Contains("hlinksldjump")) + { + // Internal slide-jump — deliberately not wrapped (no navigable href in static HTML) + shapeHrefUrl = null; + } + } + } + long x, y, cx, cy; if (overridePos != null) { @@ -238,6 +273,14 @@ public partial class PowerPointHandler || shape.ShapeProperties?.GetFirstChild() != null; var shapeClass = hasFillBg ? "shape has-fill" : "shape"; + // Open wrapper for shape-level hyperlink (before the shape
) + if (!string.IsNullOrEmpty(shapeHrefUrl)) + { + var tooltipAttr = !string.IsNullOrEmpty(shapeHrefTooltip) + ? $" title=\"{HtmlEncode(shapeHrefTooltip!)}\"" : ""; + sb.Append($" "); + } + if (!string.IsNullOrEmpty(clipPathCss)) { // For clip-path shapes: move fill to a clipped background layer, keep text unclipped @@ -254,6 +297,8 @@ public partial class PowerPointHandler else outerStyles.Add(s); } + // When wrapped in a link, add cursor:pointer to the shape
itself + if (!string.IsNullOrEmpty(shapeHrefUrl)) outerStyles.Add("cursor:pointer"); sb.Append($"
"); // Fill layer (clipped) if (fillStyles.Count > 0) @@ -274,6 +319,7 @@ public partial class PowerPointHandler } else { + if (!string.IsNullOrEmpty(shapeHrefUrl)) styles.Add("cursor:pointer"); sb.Append($"
"); } @@ -346,7 +392,10 @@ public partial class PowerPointHandler } } - sb.AppendLine("
"); + sb.Append("
"); + if (!string.IsNullOrEmpty(shapeHrefUrl)) + sb.Append("
"); + sb.AppendLine(); } // ==================== Placeholder Position Inheritance ==================== diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs index 0f14f447..e039abcb 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs @@ -1,6 +1,7 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +using System.Text.RegularExpressions; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Presentation; using Drawing = DocumentFormat.OpenXml.Drawing; @@ -11,47 +12,147 @@ public partial class PowerPointHandler { // ==================== Hyperlink helpers ==================== - /// - /// Apply a hyperlink URL to all runs in a shape. Pass "none" or "" to remove. - /// - private static void ApplyShapeHyperlink(SlidePart slidePart, Shape shape, string url) + // Result of resolving a user-supplied link string. + // Exactly one of (Id, Action) corresponds to a jump; Id may be null when Action is a named + // action that requires no relationship (firstslide, lastslide, nextslide, previousslide). + private readonly struct HyperlinkTarget { + public string? Id { get; init; } + public string? Action { get; init; } + public bool IsExternal { get; init; } + } + + /// + /// Resolve a user-supplied link string into a hyperlink target. Returns null to mean "remove". + /// Supports: + /// - Absolute URI (https://, mailto:, etc.) → external relationship + /// - slide[N] → internal slide jump (ppaction://hlinksldjump) + /// - firstslide/lastslide/nextslide/previousslide → named PowerPoint actions + /// + private static HyperlinkTarget? ResolveHyperlinkTarget(SlidePart slidePart, string url) + { + if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) + return null; + + // Named slide-action shortcuts (no relationship required) + var lower = url.Trim().ToLowerInvariant(); + switch (lower) + { + case "firstslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=firstslide" }; + case "lastslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=lastslide" }; + case "nextslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=nextslide" }; + case "previousslide" or "prevslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=previousslide" }; + } + + // Explicit slide[N] jump + var m = Regex.Match(url.Trim(), @"^slide\[(\d+)\]$", RegexOptions.IgnoreCase); + if (m.Success) + { + var slideIdx = int.Parse(m.Groups[1].Value); + var pres = slidePart.OpenXmlPackage as PresentationDocument + ?? throw new InvalidOperationException("SlidePart is not in a PresentationDocument"); + var allSlides = pres.PresentationPart?.SlideParts.ToList() + ?? throw new InvalidOperationException("PresentationPart missing"); + if (slideIdx < 1 || slideIdx > allSlides.Count) + throw new ArgumentException($"Slide jump target out of range: slide[{slideIdx}] (total {allSlides.Count})."); + var targetSlide = allSlides[slideIdx - 1]; + + // Reuse an existing slide-to-slide relationship if present + string? relId = null; + foreach (var rel in slidePart.Parts) + { + if (ReferenceEquals(rel.OpenXmlPart, targetSlide)) + { + relId = rel.RelationshipId; + break; + } + } + if (relId == null) + relId = slidePart.CreateRelationshipToPart(targetSlide); + + return new HyperlinkTarget + { + Id = relId, + Action = "ppaction://hlinksldjump", + }; + } + + // Otherwise treat as external absolute URI + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new ArgumentException( + $"Invalid hyperlink URL '{url}'. Expected an absolute URI (e.g. 'https://example.com'), " + + $"'slide[N]', or a named action (firstslide/lastslide/nextslide/previousslide)."); + var extRel = slidePart.AddHyperlinkRelationship(uri, isExternal: true); + return new HyperlinkTarget { Id = extRel.Id, IsExternal = true }; + } + + private static Drawing.HyperlinkOnClick BuildHyperlinkElement(HyperlinkTarget target, string? tooltip) + { + var hlink = new Drawing.HyperlinkOnClick(); + // r:id is required by schema — use empty string when no relationship exists (named actions). + hlink.Id = target.Id ?? ""; + if (!string.IsNullOrEmpty(target.Action)) + hlink.Action = target.Action; + if (!string.IsNullOrEmpty(tooltip)) + hlink.Tooltip = tooltip; + return hlink; + } + + /// + /// Apply a hyperlink to a shape. Pass "none" or "" to remove. + /// Stores on nvSpPr/cNvPr (canonical OOXML shape-level location) and also on every run + /// (for Office compat: some readers rely on run-level hyperlinks to render the shape as clickable). + /// + private static void ApplyShapeHyperlink(SlidePart slidePart, Shape shape, string url, string? tooltip = null) + { + var nvDp = shape.NonVisualShapeProperties?.NonVisualDrawingProperties; var allRuns = shape.Descendants().ToList(); - if (allRuns.Count == 0) return; if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) { + nvDp?.RemoveAllChildren(); foreach (var run in allRuns) run.RunProperties?.GetFirstChild()?.Remove(); return; } - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - throw new ArgumentException($"Invalid hyperlink URL '{url}'. Expected a valid absolute URI (e.g. 'https://example.com')."); - var rel = slidePart.AddHyperlinkRelationship(uri, isExternal: true); + var target = ResolveHyperlinkTarget(slidePart, url); + if (target == null) return; + + // Shape-level element on nvSpPr/cNvPr + if (nvDp != null) + { + nvDp.RemoveAllChildren(); + nvDp.AppendChild(BuildHyperlinkElement(target.Value, tooltip)); + } + + // Also mirror onto every run so in-text clicks work too foreach (var run in allRuns) { var rProps = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); rProps.RemoveAllChildren(); - rProps.InsertAt(new Drawing.HyperlinkOnClick { Id = rel.Id }, 0); + rProps.InsertAt(BuildHyperlinkElement(target.Value, tooltip), 0); } } /// - /// Apply a hyperlink URL to a single run. Pass "none" or "" to remove. + /// Apply a hyperlink to a single run. Pass "none" or "" to remove. /// - private static void ApplyRunHyperlink(SlidePart slidePart, Drawing.Run run, string url) + private static void ApplyRunHyperlink(SlidePart slidePart, Drawing.Run run, string url, string? tooltip = null) { var rProps = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); rProps.RemoveAllChildren(); - if (!string.IsNullOrEmpty(url) && !url.Equals("none", StringComparison.OrdinalIgnoreCase)) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out var runUri)) - throw new ArgumentException($"Invalid hyperlink URL '{url}'. Expected a valid absolute URI (e.g. 'https://example.com')."); - var rel = slidePart.AddHyperlinkRelationship(runUri, isExternal: true); - rProps.InsertAt(new Drawing.HyperlinkOnClick { Id = rel.Id }, 0); - } + if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) + return; + + var target = ResolveHyperlinkTarget(slidePart, url); + if (target == null) return; + rProps.InsertAt(BuildHyperlinkElement(target.Value, tooltip), 0); } /// @@ -59,12 +160,34 @@ public partial class PowerPointHandler /// private static string? ReadRunHyperlinkUrl(Drawing.Run run, OpenXmlPart part) { - var id = run.RunProperties?.GetFirstChild()?.Id?.Value; + var hlClick = run.RunProperties?.GetFirstChild(); + if (hlClick == null) return null; + var id = hlClick.Id?.Value; + var action = hlClick.Action?.Value; + + // Named actions (no relationship) → return action string directly for visibility + if (string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(action)) + return action; + if (id == null) return null; try { var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == id); - return rel?.Uri?.ToString(); + if (rel?.Uri != null) return rel.Uri.ToString(); + // Internal slide-jump: relationship is to another SlidePart, not a hyperlink relationship + if (part is SlidePart sp) + { + foreach (var irel in sp.Parts) + { + if (irel.RelationshipId == id && irel.OpenXmlPart is SlidePart target) + { + var pres = sp.OpenXmlPackage as PresentationDocument; + var idx = pres?.PresentationPart?.SlideParts.ToList().IndexOf(target) ?? -1; + if (idx >= 0) return $"slide[{idx + 1}]"; + } + } + } + return null; } catch { return null; } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs index 05cad46d..73e472d7 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs @@ -307,11 +307,13 @@ public partial class PowerPointHandler var targetRun = allRuns[runIdx - 1]; var linkValRun = properties.GetValueOrDefault("link"); + var tooltipValRun = properties.GetValueOrDefault("tooltip"); var runOnlyProps = properties - .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase)) + .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase) + && !kv.Key.Equals("tooltip", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kv => kv.Key, kv => kv.Value); var unsupported = SetRunOrShapeProperties(runOnlyProps, new List { targetRun }, shape, slidePart); - if (linkValRun != null) ApplyRunHyperlink(slidePart, targetRun, linkValRun); + if (linkValRun != null) ApplyRunHyperlink(slidePart, targetRun, linkValRun, tooltipValRun); GetSlide(slidePart).Save(); return unsupported; } @@ -338,11 +340,13 @@ public partial class PowerPointHandler var targetRun = paraRuns[runIdx - 1]; var linkVal = properties.GetValueOrDefault("link"); + var tooltipVal = properties.GetValueOrDefault("tooltip"); var runOnlyProps = properties - .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase)) + .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase) + && !kv.Key.Equals("tooltip", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kv => kv.Key, kv => kv.Value); var unsupported = SetRunOrShapeProperties(runOnlyProps, new List { targetRun }, shape, slidePart); - if (linkVal != null) ApplyRunHyperlink(slidePart, targetRun, linkVal); + if (linkVal != null) ApplyRunHyperlink(slidePart, targetRun, linkVal, tooltipVal); GetSlide(slidePart).Save(); return unsupported; } @@ -429,8 +433,14 @@ public partial class PowerPointHandler break; } case "link": + { + var paraTooltip = properties.GetValueOrDefault("tooltip"); foreach (var r in paraRuns) - ApplyRunHyperlink(slidePart, r, value); + ApplyRunHyperlink(slidePart, r, value, paraTooltip); + break; + } + case "tooltip": + // handled in tandem with "link"; standalone tooltip change is not supported here break; default: // Apply run-level properties to all runs in this paragraph @@ -1684,8 +1694,9 @@ public partial class PowerPointHandler var motionPathValue = properties.GetValueOrDefault("motionpath") ?? properties.GetValueOrDefault("motionPath"); var linkValue = properties.GetValueOrDefault("link"); + var tooltipValue = properties.GetValueOrDefault("tooltip"); var excludeKeys = new HashSet(StringComparer.OrdinalIgnoreCase) - { "animation", "animate", "motionpath", "motionPath", "link", "zorder", "z-order", "order" }; + { "animation", "animate", "motionpath", "motionPath", "link", "tooltip", "zorder", "z-order", "order" }; var shapeProps = properties .Where(kv => !excludeKeys.Contains(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); @@ -1703,7 +1714,7 @@ public partial class PowerPointHandler if (motionPathValue != null) ApplyMotionPathAnimation(slidePart, shape, motionPathValue); if (linkValue != null) - ApplyShapeHyperlink(slidePart, shape, linkValue); + ApplyShapeHyperlink(slidePart, shape, linkValue, tooltipValue); GetSlide(slidePart).Save(); return unsupported;