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:
zmworm 2026-04-19 07:24:14 +08:00
parent 46cfd95a4f
commit b48ba312fd
4 changed files with 215 additions and 29 deletions

View file

@ -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)

View file

@ -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 ====================

View file

@ -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; }
}

View file

@ -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;