Skip to content

Commit

Permalink
#6986: Media Processing Html Filter (improved) (#8806)
Browse files Browse the repository at this point in the history
* #6986 Created feature Media Processing Html Filter

* Fixing HtmlAgilityPack and upgrading FW and package version to match Orchard.Specs

* Adapting MediaProcessingHtmlFilter to IHtmlFilter breaking change

* Fixing that Orchard.MediaProcessingHtmlFilter should depend on Orchard.MediaProcessing

* Code styling

* Using regexes instead of HtmlAgilityPack, thanks GHCP

* Updating comments and code styling

* Code styling

* Reworking ProcessContent to use StringBuilder instead of replaces

* Fixing that GetAttributeRegex should find attributes with empty value

* Code styling

* Fixing that detecting the extension works regardless of casing

but it still works with Azure Blob Storage (which is case-sensitive) too

* Optimizing image tag regex

* Caching attribute regexes

* Caching attribute values of img tags

* Simplifying attribute value cache

---------

Co-authored-by: Arjan Noordende <[email protected]>
  • Loading branch information
BenedekFarkas and anoordende authored Dec 7, 2024
1 parent 5c57a30 commit 18ce08f
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 1 deletion.
5 changes: 5 additions & 0 deletions src/Orchard.Web/Modules/Orchard.MediaProcessing/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Orchard.MediaProcessing {
public static class Features {
public const string OrchardMediaProcessingHtmlFilter = "Orchard.MediaProcessingHtmlFilter";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Orchard.ContentManagement;
using Orchard.Environment.Extensions;
using Orchard.Forms.Services;
using Orchard.Logging;
using Orchard.MediaProcessing.Models;
using Orchard.MediaProcessing.Services;
using Orchard.Services;

namespace Orchard.MediaProcessing.Filters {
/// <summary>
/// Resizes any images in HTML provided by parts that support IHtmlFilter and sets an alt text if not already supplied.
/// </summary>
[OrchardFeature(Features.OrchardMediaProcessingHtmlFilter)]
public class MediaProcessingHtmlFilter : IHtmlFilter {

private readonly IWorkContextAccessor _wca;
private readonly IImageProfileManager _profileManager;

private MediaHtmlFilterSettingsPart _settingsPart;
private static readonly Regex _imageTagRegex = new Regex(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly ConcurrentDictionary<string, Regex> _attributeRegexes = new ConcurrentDictionary<string, Regex>();
private static readonly ConcurrentDictionary<string, string> _attributeValues = new ConcurrentDictionary<string, string>();
private static readonly Dictionary<string, string> _validExtensions = new Dictionary<string, string> {
{ ".jpeg", "jpg" }, // For example: .jpeg supports compression (quality), format to 'jpg'.
{ ".jpg", "jpg" },
{ ".png", null }
};

public MediaProcessingHtmlFilter(IWorkContextAccessor wca, IImageProfileManager profileManager) {
_profileManager = profileManager;
_wca = wca;

Logger = NullLogger.Instance;
}

public ILogger Logger { get; set; }

public MediaHtmlFilterSettingsPart Settings {
get {
if (_settingsPart == null) {
_settingsPart = _wca.GetContext().CurrentSite.As<MediaHtmlFilterSettingsPart>();
}

return _settingsPart;
}
}

public string ProcessContent(string text, HtmlFilterContext context) {
if (string.IsNullOrWhiteSpace(text) || context.Flavor != "html") {
return text;
}

var matches = _imageTagRegex.Matches(text);

if (matches.Count == 0) {
return text;
}

var offset = 0; // This tracks where last image tag ended in the original HTML.
var newText = new StringBuilder();

foreach (Match match in matches) {
newText.Append(text.Substring(offset, match.Index - offset));
offset = match.Index + match.Length;
var imgTag = match.Value;
var processedImgTag = ProcessImageContent(imgTag);

if (Settings.PopulateAlt) {
processedImgTag = ProcessImageAltContent(processedImgTag);
}

newText.Append(processedImgTag);
}

newText.Append(text.Substring(offset));

return newText.ToString();
}

private string ProcessImageContent(string imgTag) {
if (imgTag.Contains("noresize")) {
return imgTag;
}

var src = GetAttributeValue(imgTag, "src");
var ext = string.IsNullOrEmpty(src) ? null : Path.GetExtension(src).ToLowerInvariant();
var width = GetAttributeValueInt(imgTag, "width");
var height = GetAttributeValueInt(imgTag, "height");

if (width > 0 && height > 0
&& !string.IsNullOrEmpty(src)
&& !src.Contains("_Profiles")
&& _validExtensions.ContainsKey(ext)) {
try {
// If the image has a combination of width, height and valid extension, that is not already in
// _Profiles, then process the image.
var newSrc = TryGetImageProfilePath(src, ext, width, height);
imgTag = SetAttributeValue(imgTag, "src", newSrc);
}
catch (Exception ex) {
Logger.Error(ex, "Unable to process Html Dynamic image profile for '{0}'", src);
}
}

return imgTag;
}

private string TryGetImageProfilePath(string src, string ext, int width, int height) {
var filters = new List<FilterRecord> {
// Factor in a minimum width and height with respect to higher pixel density devices.
CreateResizeFilter(width * Settings.DensityThreshold, height * Settings.DensityThreshold)
};

if (_validExtensions[ext] != null && Settings.Quality < 100) {
filters.Add(CreateFormatFilter(Settings.Quality, _validExtensions[ext]));
}

var profileName = string.Format(
"Transform_Resize_w_{0}_h_{1}_m_Stretch_a_MiddleCenter_c_{2}_d_@{3}x",
width,
height,
Settings.Quality,
Settings.DensityThreshold);

return _profileManager.GetImageProfileUrl(src, profileName, null, filters.ToArray());
}

private FilterRecord CreateResizeFilter(int width, int height) {
// Because the images can be resized in the HTML editor, we must assume that the image is of the exact desired
// dimensions and that stretch is an appropriate mode. Note that the default is to never upscale images.
var state = new Dictionary<string, string> {
{ "Width", width.ToString() },
{ "Height", height.ToString() },
{ "Mode", "Stretch" },
{ "Alignment", "MiddleCenter" },
{ "PadColor", "" }
};

return new FilterRecord {
Category = "Transform",
Type = "Resize",
State = FormParametersHelper.ToString(state)
};
}

private FilterRecord CreateFormatFilter(int quality, string format) {
var state = new Dictionary<string, string> {
{ "Quality", quality.ToString() },
{ "Format", format },
};

return new FilterRecord {
Category = "Transform",
Type = "Format",
State = FormParametersHelper.ToString(state)
};
}

private string ProcessImageAltContent(string imgTag) {
var src = GetAttributeValue(imgTag, "src");
var alt = GetAttributeValue(imgTag, "alt");

if (string.IsNullOrEmpty(alt) && !string.IsNullOrEmpty(src)) {
var text = Path.GetFileNameWithoutExtension(src).Replace("-", " ").Replace("_", " ");
imgTag = SetAttributeValue(imgTag, "alt", text);
}

return imgTag;
}

private string GetAttributeValue(string tag, string attributeName) =>
_attributeValues
.GetOrAdd($"{tag}_{attributeName}", _ => {
var match = GetAttributeRegex(attributeName).Match(tag);
return match.Success ? match.Groups[1].Value : null;
});

private int GetAttributeValueInt(string tag, string attributeName) =>
int.TryParse(GetAttributeValue(tag, attributeName), out int result) ? result : 0;

private string SetAttributeValue(string tag, string attributeName, string value) {
var attributeRegex = GetAttributeRegex(attributeName);
var newAttribute = $"{attributeName}=\"{value}\"";

if (attributeRegex.IsMatch(tag)) {
return attributeRegex.Replace(tag, newAttribute);
}
else {
return tag.Insert(tag.Length - 1, $" {newAttribute}");
}
}

private Regex GetAttributeRegex(string attributeName) =>
_attributeRegexes.GetOrAdd(
attributeName,
name => new Regex($@"\b{name}\s*=\s*[""']?([^""'\s>]*)[""']?", RegexOptions.IgnoreCase | RegexOptions.Compiled));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Orchard.ContentManagement;
using Orchard.ContentManagement.Handlers;
using Orchard.Environment.Extensions;
using Orchard.Localization;
using Orchard.MediaProcessing.Models;

namespace Orchard.MediaProcessing.Handlers {
[OrchardFeature(Features.OrchardMediaProcessingHtmlFilter)]
public class MediaHtmlFilterSettingsPartHandler : ContentHandler {
public MediaHtmlFilterSettingsPartHandler() {
T = NullLocalizer.Instance;

Filters.Add(new ActivatingFilter<MediaHtmlFilterSettingsPart>("Site"));
Filters.Add(new TemplateFilterForPart<MediaHtmlFilterSettingsPart>(
"MediaHtmlFilterSettings",
"Parts.MediaProcessing.MediaHtmlFilterSettings",
"media"));
}

public Localizer T { get; set; }

protected override void GetItemMetadata(GetContentItemMetadataContext context) {
if (context.ContentItem.ContentType != "Site") return;

base.GetItemMetadata(context);
context.Metadata.EditorGroupInfo.Add(new GroupInfo(T("Media")));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Orchard.ContentManagement;

namespace Orchard.MediaProcessing.Models {
public class MediaHtmlFilterSettingsPart : ContentPart {
public int DensityThreshold {
get { return this.Retrieve(x => x.DensityThreshold, 2); }
set { this.Store(x => x.DensityThreshold, value); }
}

public int Quality {
get { return this.Retrieve(x => x.Quality, 95); }
set { this.Store(x => x.Quality, value); }
}

public bool PopulateAlt {
get { return this.Retrieve(x => x.PopulateAlt, true); }
set { this.Store(x => x.PopulateAlt, value); }
}
}
}
8 changes: 7 additions & 1 deletion src/Orchard.Web/Modules/Orchard.MediaProcessing/Module.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ Version: 1.10.3
OrchardVersion: 1.10.3
Description: Module for processing Media e.g. image resizing
Category: Media
Dependencies: Orchard.Forms
Dependencies: Orchard.Forms
Features:
Orchard.MediaProcessingHtmlFilter:
Name: Media Processing HTML Filter
Description: Dynamically resizes images to their height and width attributes
Category: Media
Dependencies: Orchard.MediaProcessing
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@
<Content Include="Web.config" />
<Content Include="Scripts\Web.config" />
<Content Include="Styles\Web.config" />
<Compile Include="Constants.cs" />
<Compile Include="Filters\MediaProcessingHtmlFilter.cs" />
<Compile Include="Handlers\MediaHtmlFilterSettingsPartHandler.cs" />
<Compile Include="Models\MediaHtmlFilterSettingsPart.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Content Include="Module.txt" />
</ItemGroup>
Expand Down Expand Up @@ -192,6 +196,9 @@
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<Content Include="Views\EditorTemplates\Parts.MediaProcessing.MediaHtmlFilterSettings.cshtml" />
</ItemGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">10.0</VisualStudioVersion>
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@model Orchard.MediaProcessing.Models.MediaHtmlFilterSettingsPart

<fieldset>
<legend>@T("Media Processing Filter")</legend>
<div>
@Html.LabelFor(m => m.DensityThreshold, T("Density Threshold"))
@Html.DropDownListFor(m => m.DensityThreshold, new List<SelectListItem>(new[] {
new SelectListItem { Value = "1", Text = "@1x" },
new SelectListItem { Value = "2", Text = "@2x" },
new SelectListItem { Value = "3", Text = "@3x" },
new SelectListItem { Value = "4", Text = "@4x" }
}))
<span class="hint">@T("The image will only be reduced if at least this pixel density is maintained.")</span>
</div>
<div>
@Html.LabelFor(m => m.Quality, T("JPEG Quality"))
@Html.TextBoxFor(i => i.Quality, new { @type = "number", @min = "0", @max = "100" }) %
<span class="hint">@T("The quality level to apply on JPEG's, where 100 is full-quality (no compression).")</span>
</div>
<div>
@Html.CheckBoxFor(i => i.PopulateAlt)
@Html.LabelFor(m => m.PopulateAlt, T("Populate Empty Alt Tags").Text, new { @class = "forcheckbox" })
<span class="hint">@T("Populate an Alt tag based on the file name if the Alt tag is empty")</span>
</div>
</fieldset>

0 comments on commit 18ce08f

Please sign in to comment.