Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

## [16.1.0] - 2025-07-16

### Added

- [SIL.Windows.Forms] Added PortableClipboard.CanGetImage()
- [ClipboardTestApp] Restored this test program and added tests for PortableClipboard.CanGetImage() and GetImageFromClipboard()
- [SIL.Windows.Forms] Check in debug mode to alert developer when using the SILAboutBox to display HTML that has links but has neither a base target in the head element (`<base target="_blank"> element`) nor explicit `target="_blank"` attributes for any of the links when they have not handled the Navigating event to customize the navigation behavior. In this situation links will likely open directly in the About browser window and will probably not behave as expected.
- [SIL.Windows.Forms] Added SILAboutBox.Navigating and SILAboutBox.Navigated events to allow callers to customize how HTML links in the embedded browser are handled.
- [SIL.Windows.Forms] Added SILAboutBox.AllowExternalLinksToOpenInsideAboutBox property to control whether a `<base target="_blank" rel="noopener noreferrer">` line is automatically added to the HTML (if missing) to ensure links open in the system browser rather than within the About dialog box.
- [SIL.Core] Added static HtmlUtils class with methods for handling HTML to be displayed in browser controls (e.g., `SILAboutBox`), including support for fixing missing target attributes on links and copying simple asset files when creating a temp HTML file to display.

### Fixed

- [SIL.Windows.Forms] In `CustomDropDown.OnOpening`, fixed check that triggers timer to stop.
- [SIL.Windows.Forms] Fixed HtmlBrowserHandled.OnNewWindow to open external URLs (`target="_blank"`) in the system’s default browser instead of Internet Explorer. This improves behavior in SILAboutBox and other components that use the embedded browser.

### Changed

Expand Down Expand Up @@ -590,7 +597,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [SIL.NUnit3Compatibility] new project/package that allows to use NUnit3 syntax with NUnit2
projects

[Unreleased]: https://github.com/sillsdev/libpalaso/compare/v16.0.0...master
[Unreleased]: https://github.com/sillsdev/libpalaso/compare/v16.1.0...master
[16.1.0]: https://github.com/sillsdev/libpalaso/compare/v16.0.0...master
[16.0.0]: https://github.com/sillsdev/libpalaso/compare/v15.0.0...v16.0.0
[15.0.0]: https://github.com/sillsdev/libpalaso/compare/v14.1.1...v15.0.0
[14.1.1]: https://github.com/sillsdev/libpalaso/compare/v14.1.0...v14.1.1
Expand Down
337 changes: 337 additions & 0 deletions SIL.Core.Tests/Html/HtmlUtilsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
using NUnit.Framework;
using System.IO;
using SIL.Core;
using SIL.IO;
using static System.IO.Path;
using static System.StringComparison;

namespace SIL.Tests.Html
{
[TestFixture]
public class HtmlUtilsTests
{
private const string htmlWithNoHead = @"<html>
<body>
<h3>Stuff</h3>
<p>Here is something you need to know.</p>
</body>
</html>";

private const string htmlWithSelfClosingEmptyHead = @"<html>
<head/>
<body>
<h3>Stuff</h3>
<p>Here is something you need to know.</p>
</body>
</html>";

private const string htmlWithNonEmptyHeadButNoTarget = @"<html>
<head><meta charset='UTF-8' /></head>
<body>
<h3>Stuff</h3>
<p>Here is something you need to know.</p>
</body>
</html>";

[TestCase(@"<html>
<head><base target=""_blank"" rel=""noopener noreferrer""></head>
<body>
<p>Good</p>
</body>
</html>")]
[TestCase(@"<html>
<head><base target=""_blank""></head>
<body>
<p>Stuff.</p>
</body>
</html>")]
[TestCase(@"<html>
<head><base target=""_self""></head>
<body>
<p>Stuff.</p>
</body>
</html>")]
public void HasBaseTarget_Yes_ReturnsTrue(string html)
{
Assert.That(HtmlUtils.HasBaseTarget(html), Is.True);
}

[TestCase(htmlWithNoHead)]
[TestCase(htmlWithSelfClosingEmptyHead)]
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
public void HasBaseTarget_No_ReturnsFalse(string html)
{
Assert.That(HtmlUtils.HasBaseTarget(html), Is.False);
}

[TestCase("")]
[TestCase(null)]
public void HandleMissingLinkTargets_EmptyHtml_ReturnsNull(string html)
{
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.IsNull(result);
}

[TestCase(htmlWithNoHead)]
[TestCase(htmlWithSelfClosingEmptyHead)]
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
public void HandleMissingLinkTargets_HtmlWithNoLinks_ReturnsNull(string html)
{
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.IsNull(result);
}

[TestCase(true)]
[TestCase(false)]
public void HandleMissingLinkTargets_ExternalLinkWithoutTarget_AddsBaseTarget(
bool useDoubleQuotes)
{
var html = @"<html>
<head></head>
<body>
<a href='http://example.com'>External</a>
</body>
</html>";
if (useDoubleQuotes)
html = html.Replace("'", @"""");
var origBody = html.Substring(html.IndexOf("<body>", Ordinal));
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
Assert.That(result.Substring(result.IndexOf("<body>", Ordinal)),
Is.EqualTo(origBody));
}

[TestCase(true)]
[TestCase(false)]
public void HandleMissingLinkTargets_ExternalLinkWithTarget_ReturnsNull(
bool useDoubleQuotes)
{
var html = @"<html>
<head></head>
<body>
<a href='https://example.com' target='_blank'>External</a>
</body>
</html>";
if (useDoubleQuotes)
html = html.Replace("'", @"""");
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.That(result, Is.Null, "No need to modify; explicit target already present");
}

[TestCase(true)]
[TestCase(false)]
public void HandleMissingLinkTargets_OnlyInternalLinks_ReturnsNull(
bool useDoubleQuotes)
{
var html = @"<html>
<head></head>
<body>
<a href='#section1'>Internal</a>
<a href='mailto:someone@example.com'>Email</a>
<h4 id='section1'>This is the internal section</h4>
<p>You jumped here using an internal anchor link.</p>
</body>
</html>";
if (useDoubleQuotes)
html = html.Replace("'", @"""");
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.That(result, Is.Null, "No need to modify; all links are internal/special");
}

[Test]
public void HandleMissingLinkTargets_InternalAnchor_GetsTargetSelf()
{
var html = @"<html><head></head><body><a href=""#section1"">Jump</a> and
<a href='http://example.com'>Go</a>!</body></html>";

var origTail = html.Substring(html.IndexOf("Jump</a> and", Ordinal));

var result = HtmlUtils.HandleMissingLinkTargets(html);

Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
Assert.That(result, Does.Contain(@"<a href=""#section1"" target=""_self"">Jump</a>"));
Assert.That(result, Does.EndWith(origTail));
}

[Test]
public void HandleMissingLinkTargets_Mailto_GetsTargetSelf()
{
var html = @"<html><head></head><body><a href='http://example.com'>Go</a> here to
<a href='mailto:someone@example.com'>Email</a> someone.</body></html>";

int bodyStart = html.IndexOf("<body>", Ordinal);
var origBodyStart = html.Substring(bodyStart,
html.IndexOf("<a href='mailto", Ordinal) - bodyStart);

var result = HtmlUtils.HandleMissingLinkTargets(html);

Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
Assert.That(result, Does.Contain(origBodyStart));
Assert.That(result, Does.EndWith("<a href='mailto:someone@example.com' " +
@"target=""_self"">Email</a> someone.</body></html>"));
}

[TestCase(@"href=""#internal"" target=""_self""")]
[TestCase(@"target=""_self"" href=""#internal""")]
[TestCase(@"target='_self' href=""#internal""")]
[TestCase(@"href='#internal' target=""_self""")]
public void HandleMissingLinkTargets_InternalLink_WithTargetAlready_OnlyBaseTargetAdded(
string internalLinkAttributes)
{
var html = $"<html><head></head><body><a {internalLinkAttributes}>Stay</a>" +
@"alert as you <a href=""www.example.com"">walk</a>.</body></html>";

var origBody = html.Substring(html.IndexOf("<body>", Ordinal));
var result = HtmlUtils.HandleMissingLinkTargets(html);
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
Assert.That(result.Substring(result.IndexOf("<body>", Ordinal)),
Is.EqualTo(origBody));
}

[TestCase("www.example.com")]
[TestCase("http://www.example.com")]
[TestCase("https://www.example.com")]
public void IsExternalHref_IsExternal_ReturnsTrue(string href)
{
Assert.That(HtmlUtils.IsExternalHref(href), Is.True);
}

[TestCase("#internal")]
[TestCase("mailto:someone@example.com")]
[TestCase("tel:8008008000")]
[TestCase("")]
[TestCase(null)]
public void IsExternalHref_IsNotExternal_ReturnsFalse(string href)
{
Assert.That(HtmlUtils.IsExternalHref(href), Is.False);
}

[TestCase(htmlWithNoHead)]
[TestCase(htmlWithSelfClosingEmptyHead)]
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
public void InjectBaseTarget_Missing_AddsHeadElementWithBaseTarget(string html)
{
var result = HtmlUtils.InjectBaseTarget(html);
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
}

[TestCase("")]
[TestCase(@"<p><a name=""gumby""/></p>")]
[TestCase(@"<p><a href=""https://www.example.com""/></p>")]
public void InjectBaseTarget_AlreadyHasBaseTarget_ReturnsOriginalHtml(string body)
{
var html = $"<html><head><base target='_blank'></head><body>{body}</body></html>";
var result = HtmlUtils.InjectBaseTarget(html);
Assert.That(result, Is.EqualTo(html));
}
}

[TestFixture]
public class HtmlUtilsCreatePatchedTempHtmlFileTests
{
private string _testDir;
private TempFile _modifiedHtml;
private string _htmlPath;

[SetUp]
public void Setup()
{
_modifiedHtml = TempFile.WithFilenameInTempFolder("about.html");
_testDir = GetDirectoryName(_modifiedHtml.Path);
_htmlPath = _modifiedHtml.Path;
}

[TearDown]
public void Teardown()
{
_modifiedHtml.Dispose();
}

[TestCase("")]
[TestCase("./")]
[TestCase(" ")]
public void SimpleCssLink_AssetCopied(string prefix)
{
const string cssName = "style.css";
var cssPath = Combine(_testDir, cssName);
File.WriteAllText(cssPath, "body { background: black; }");

var html = $@"<html><head>
<link rel=""stylesheet"" href=""{prefix}{cssName}""></head><body>hello</body></html>";
File.WriteAllText(_htmlPath, html);

using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);

var tempDir = GetDirectoryName(tempFile.Path);
Assert.That(tempFile.Path, Does.Exist);
Assert.That(Combine(tempDir, cssName), Does.Exist);
}

[TestCase("")]
[TestCase("./")]
[TestCase(" ")]
[TestCase(" ./")]
public void MultipleSimpleAssets_AllCopied(string prefix)
{
const string cssName = "style.css";
const string jsName = "script.js";
const string logoName = "logo.png";
File.WriteAllText(Combine(_testDir, cssName), "css");
File.WriteAllText(Combine(_testDir, jsName), "js");
File.WriteAllText(Combine(_testDir, logoName), "png");

var html = $@"<html><head>
<link rel=""stylesheet"" href = ""{prefix}{cssName}"">
<script src=""{prefix}{jsName}""></script>
</head><body><img src=""{prefix}{logoName}""></body></html>";
File.WriteAllText(_htmlPath, html);

using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);
var tempDir = GetDirectoryName(tempFile.Path);

Assert.That(File.Exists(Combine(tempDir, cssName)), Is.True);
Assert.That(File.Exists(Combine(tempDir, jsName)), Is.True);
Assert.That(File.Exists(Combine(tempDir, logoName)), Is.True);
}

[Test]
public void ExternalLinks_Ignored()
{
const string html = @"<html><head>
<link rel=""stylesheet"" href=""https://example.com/style.css"">
</head><body>hello</body></html>";
File.WriteAllText(_htmlPath, html);

using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);

var tempDir = GetDirectoryName(tempFile.Path);
Assert.That(File.Exists(tempFile.Path), Is.True);
Assert.That(Directory.GetFiles(tempDir).Length, Is.EqualTo(1),
"Should not attempt to copy external resources.");
}

/// <summary>
/// Since we're purposefully trying to keep things simple by ignoring relative/
/// subdirectory assets, this test ensures that we don't attempt to copy them.
/// </summary>
[TestCase(@"\")]
[TestCase("/")]
public void SubdirectoryAsset_NotCopied(string slash)
{
var assetsDir = Combine(_testDir, "assets");
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Combine(assetsDir, "style.css"), "should not copy");

var html = $@"<html><head>
<link rel=""stylesheet"" href=""assets{slash}style.css"">
</head><body>hello</body></html>";
File.WriteAllText(_htmlPath, html);

using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);

var tempDir = GetDirectoryName(tempFile.Path);
Assert.That(Directory.GetFiles(tempDir).Length, Is.EqualTo(1),
"Subdirectory assets should not be copied.");
}
}
}
6 changes: 3 additions & 3 deletions SIL.Core/Acknowledgements/AcknowledgementAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace SIL.Acknowledgements
/// will be able to collect your project's dependencies and display them in your project's SILAboutBox.
/// You just need to add the string #DependencyAcknowledgements# (probably surrounded by a &lt;ul&gt; element)
/// to your project's about box html file and the AcknowledgementsProvider will replace it
/// with the each collected Acknowledgement set within a &lt;li&gt; element.
/// with each collected Acknowledgement set within a &lt;li&gt; element.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public class AcknowledgementAttribute : Attribute
Expand Down Expand Up @@ -41,7 +41,7 @@ public AcknowledgementAttribute(string key)
/// Key should be something that will be unique and stable (as much as possible),
/// but not version-based, so we can eliminate duplicates. This is a required field.
///
/// For now we are just using the Name of the Reference as listed in Visual Studio. In the .csproj file,
/// For now, we are just using the Name of the Reference as listed in Visual Studio. In the .csproj file,
/// this can be found in the Include attribute of the Reference element up until the first comma.
/// </summary>
public string Key { get; }
Expand Down Expand Up @@ -102,7 +102,7 @@ public string Copyright

/// <summary>
/// If we can't find the file using the Location, return null.
/// Otherwise returns the located executable file's FileVersionInfo.
/// Otherwise, returns the located executable file's FileVersionInfo.
/// </summary>
private FileVersionInfo ExtractExecutableVersionInfo()
{
Expand Down
Loading
Loading