mirror of
https://github.com/iOfficeAI/OfficeCLI
synced 2026-04-21 13:37:23 +00:00
feat(pptx): hyperlink tooltip, slide-jump actions, and shape-level html anchor
Three gaps remaining from R14. Hyperlinks now accept tooltip= alongside link= on both run and shape Add / Set. ApplyRunHyperlink and ApplyShapeHyperlink set the Tooltip attribute on HyperlinkOnClick. link= gains internal-jump support: - slide[N] creates a slide-to-slide relationship and emits action="ppaction://hlinksldjump" - firstslide / lastslide / nextslide / previousslide emit the corresponding ppaction://hlinkshowjump?jump=... (PowerPoint-native) ReadRunHyperlinkUrl resolves both forms back to the original string. HTML preview wraps shape-level hyperlinks in an <a> tag. Shape hlinkClick now lives on nvSpPr/cNvPr (canonical shape location) in addition to runs so the HTML renderer can detect it from the shape tree; runs still carry the inline anchor from R14 so text stays clickable inline. External URLs get a wrapping anchor with rel="noopener" target="_blank"; cursor:pointer affordance added. Internal slide-jump links are intentionally not wrapped (no navigable href in a static HTML preview).
This commit is contained in:
parent
46cfd95a4f
commit
b48ba312fd
4 changed files with 215 additions and 29 deletions
|
|
@ -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<string>(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
|
|||
|
|
@ -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 <div> in <a> 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<Drawing.HyperlinkOnClick>();
|
||||
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<Drawing.BlipFill>() != null;
|
||||
var shapeClass = hasFillBg ? "shape has-fill" : "shape";
|
||||
|
||||
// Open <a> wrapper for shape-level hyperlink (before the shape <div>)
|
||||
if (!string.IsNullOrEmpty(shapeHrefUrl))
|
||||
{
|
||||
var tooltipAttr = !string.IsNullOrEmpty(shapeHrefTooltip)
|
||||
? $" title=\"{HtmlEncode(shapeHrefTooltip!)}\"" : "";
|
||||
sb.Append($" <a class=\"shape-link\" href=\"{HtmlEncode(shapeHrefUrl!)}\" rel=\"noopener\" target=\"_blank\"{tooltipAttr} style=\"display:contents;cursor:pointer\">");
|
||||
}
|
||||
|
||||
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 <div> itself
|
||||
if (!string.IsNullOrEmpty(shapeHrefUrl)) outerStyles.Add("cursor:pointer");
|
||||
sb.Append($" <div class=\"{shapeClass}\"{dataPathAttr} style=\"{string.Join(";", outerStyles)}\">");
|
||||
// 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($" <div class=\"{shapeClass}\"{dataPathAttr} style=\"{string.Join(";", styles)}\">");
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +392,10 @@ public partial class PowerPointHandler
|
|||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.Append("</div>");
|
||||
if (!string.IsNullOrEmpty(shapeHrefUrl))
|
||||
sb.Append("</a>");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// ==================== Placeholder Position Inheritance ====================
|
||||
|
|
|
|||
|
|
@ -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 ====================
|
||||
|
||||
/// <summary>
|
||||
/// Apply a hyperlink URL to all runs in a shape. Pass "none" or "" to remove.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static void ApplyShapeHyperlink(SlidePart slidePart, Shape shape, string url, string? tooltip = null)
|
||||
{
|
||||
var nvDp = shape.NonVisualShapeProperties?.NonVisualDrawingProperties;
|
||||
var allRuns = shape.Descendants<Drawing.Run>().ToList();
|
||||
if (allRuns.Count == 0) return;
|
||||
|
||||
if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
nvDp?.RemoveAllChildren<Drawing.HyperlinkOnClick>();
|
||||
foreach (var run in allRuns)
|
||||
run.RunProperties?.GetFirstChild<Drawing.HyperlinkOnClick>()?.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<Drawing.HyperlinkOnClick>();
|
||||
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<Drawing.HyperlinkOnClick>();
|
||||
rProps.InsertAt(new Drawing.HyperlinkOnClick { Id = rel.Id }, 0);
|
||||
rProps.InsertAt(BuildHyperlinkElement(target.Value, tooltip), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Drawing.HyperlinkOnClick>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -59,12 +160,34 @@ public partial class PowerPointHandler
|
|||
/// </summary>
|
||||
private static string? ReadRunHyperlinkUrl(Drawing.Run run, OpenXmlPart part)
|
||||
{
|
||||
var id = run.RunProperties?.GetFirstChild<Drawing.HyperlinkOnClick>()?.Id?.Value;
|
||||
var hlClick = run.RunProperties?.GetFirstChild<Drawing.HyperlinkOnClick>();
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Drawing.Run> { 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<Drawing.Run> { 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<string>(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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue