diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Constants.cs b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Constants.cs new file mode 100644 index 00000000000..42f09007720 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Constants.cs @@ -0,0 +1,5 @@ +namespace Orchard.MediaProcessing { + public static class Features { + public const string OrchardMediaProcessingHtmlFilter = "Orchard.MediaProcessingHtmlFilter"; + } +} diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Filters/MediaProcessingHtmlFilter.cs b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Filters/MediaProcessingHtmlFilter.cs new file mode 100644 index 00000000000..2ff487da2bf --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Filters/MediaProcessingHtmlFilter.cs @@ -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 { + /// + /// Resizes any images in HTML provided by parts that support IHtmlFilter and sets an alt text if not already supplied. + /// + [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(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly ConcurrentDictionary _attributeRegexes = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary _attributeValues = new ConcurrentDictionary(); + private static readonly Dictionary _validExtensions = new Dictionary { + { ".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(); + } + + 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 { + // 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 { + { "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 { + { "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)); + } +} diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Handlers/MediaHtmlFilterSettingsPartHandler.cs b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Handlers/MediaHtmlFilterSettingsPartHandler.cs new file mode 100644 index 00000000000..0f2e80abc65 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Handlers/MediaHtmlFilterSettingsPartHandler.cs @@ -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("Site")); + Filters.Add(new TemplateFilterForPart( + "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"))); + } + } +} diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Models/MediaHtmlFilterSettingsPart.cs b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Models/MediaHtmlFilterSettingsPart.cs new file mode 100644 index 00000000000..60ad490bf35 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Models/MediaHtmlFilterSettingsPart.cs @@ -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); } + } + } +} diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Module.txt b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Module.txt index c6cdefc65f8..ca2c22c3d08 100644 --- a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Module.txt +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Module.txt @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Orchard.MediaProcessing.csproj b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Orchard.MediaProcessing.csproj index 64566bec40f..e4c6314cf3d 100644 --- a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Orchard.MediaProcessing.csproj +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Orchard.MediaProcessing.csproj @@ -102,6 +102,10 @@ + + + + @@ -192,6 +196,9 @@ + + + 10.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) diff --git a/src/Orchard.Web/Modules/Orchard.MediaProcessing/Views/EditorTemplates/Parts.MediaProcessing.MediaHtmlFilterSettings.cshtml b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Views/EditorTemplates/Parts.MediaProcessing.MediaHtmlFilterSettings.cshtml new file mode 100644 index 00000000000..384afeec20c --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.MediaProcessing/Views/EditorTemplates/Parts.MediaProcessing.MediaHtmlFilterSettings.cshtml @@ -0,0 +1,25 @@ +@model Orchard.MediaProcessing.Models.MediaHtmlFilterSettingsPart + + + @T("Media Processing Filter") + + @Html.LabelFor(m => m.DensityThreshold, T("Density Threshold")) + @Html.DropDownListFor(m => m.DensityThreshold, new List(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" } + })) + @T("The image will only be reduced if at least this pixel density is maintained.") + + + @Html.LabelFor(m => m.Quality, T("JPEG Quality")) + @Html.TextBoxFor(i => i.Quality, new { @type = "number", @min = "0", @max = "100" }) % + @T("The quality level to apply on JPEG's, where 100 is full-quality (no compression).") + + + @Html.CheckBoxFor(i => i.PopulateAlt) + @Html.LabelFor(m => m.PopulateAlt, T("Populate Empty Alt Tags").Text, new { @class = "forcheckbox" }) + @T("Populate an Alt tag based on the file name if the Alt tag is empty") + +