diff --git a/Directory.Build.props b/Directory.Build.props index 00d07c7..52406a1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.5.1 + 0.6.0 Code Happy, LLC Code Happy, LLC HTML/CSS To Image API diff --git a/src/HtmlCssToImage.Blazor/HCTI_OgMetaTag_Base.cs b/src/HtmlCssToImage.Blazor/HCTI_OgMetaTag_Base.cs index 5a4e54a..b6e59a4 100644 --- a/src/HtmlCssToImage.Blazor/HCTI_OgMetaTag_Base.cs +++ b/src/HtmlCssToImage.Blazor/HCTI_OgMetaTag_Base.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; +using HtmlCssToImage.Models; using HtmlCssToImage.Models.Requests; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -15,6 +16,8 @@ public abstract class HCTI_OgMetaTagBase:ComponentBase [Parameter] public string? OgMetaType { get; set; } + [Parameter] public RenderImageOptions? RenderOptions { get; set; } + protected string? MetaUrl { get; set; } protected abstract void SetMetaUrl(); @@ -44,4 +47,4 @@ private void BuildMetaRenderTree(RenderTreeBuilder builder) builder.AddAttribute(2, "content", MetaUrl); builder.CloseElement(); } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.Blazor/HCTI_Templated_OgMetaTag.cs b/src/HtmlCssToImage.Blazor/HCTI_Templated_OgMetaTag.cs index 0caf536..8244c8f 100644 --- a/src/HtmlCssToImage.Blazor/HCTI_Templated_OgMetaTag.cs +++ b/src/HtmlCssToImage.Blazor/HCTI_Templated_OgMetaTag.cs @@ -23,22 +23,59 @@ protected override void SetMetaUrl() { if (TemplateValues is JsonObject jo) { - MetaUrl= HtmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, jo, TemplateVersion); + MetaUrl = RenderOptions is null + ? HtmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, jo, TemplateVersion) + : HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + jo, + TemplateVersion, + RenderOptions); } else if(TemplateValues!=null) { if (TypeInfo != null) { - MetaUrl= HtmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, TemplateValues, TypeInfo, TemplateVersion); + MetaUrl = RenderOptions is null + ? HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TypeInfo, + TemplateVersion) + : HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TypeInfo, + TemplateVersion, + RenderOptions); }else if (JsonSerializerOptions != null) { - MetaUrl= HtmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, TemplateValues, JsonSerializerOptions, TemplateVersion); + MetaUrl = RenderOptions is null + ? HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + JsonSerializerOptions, + TemplateVersion) + : HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + JsonSerializerOptions, + TemplateVersion, + RenderOptions); } else { - MetaUrl= HtmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, TemplateValues, TemplateVersion); + MetaUrl = RenderOptions is null + ? HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TemplateVersion) + : HtmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TemplateVersion, + RenderOptions); } } } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.Blazor/HCTI_Url_OgMetaTag.cs b/src/HtmlCssToImage.Blazor/HCTI_Url_OgMetaTag.cs index df13726..a3ab3d4 100644 --- a/src/HtmlCssToImage.Blazor/HCTI_Url_OgMetaTag.cs +++ b/src/HtmlCssToImage.Blazor/HCTI_Url_OgMetaTag.cs @@ -16,7 +16,9 @@ public class HCTI_Url_OgMetaTag:HCTI_OgMetaTagBase protected override void SetMetaUrl() { - MetaUrl= HtmlCssToImageClient.CreateAndRenderUrl(ImageRequest); + MetaUrl = RenderOptions is null + ? HtmlCssToImageClient.CreateAndRenderUrl(ImageRequest) + : HtmlCssToImageClient.CreateAndRenderUrl(ImageRequest, RenderOptions); } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.Blazor/README.md b/src/HtmlCssToImage.Blazor/README.md index 56fa6f7..3845c3b 100644 --- a/src/HtmlCssToImage.Blazor/README.md +++ b/src/HtmlCssToImage.Blazor/README.md @@ -62,6 +62,7 @@ Use the `HCTI_Templated_OgMetaTag` component to generate OG meta tags using a | `TypeInfo` | | A `System.Text.Json.JsonTypeInfo` instance that will be used to serialize `TemplateValues` | | `JsonSerializerOptions` | | A `System.Text.Json.JsonSerializerOptions` instance that will be used to serialize `TemplateValues` if provided | | `TemplateVersion` | | An optional version of the template to use, if not specified, the latest version will be used | +| `RenderOptions` | | Optional output format, sizing, DPI, and cropping options supplied as a `RenderImageOptions` instance. | | `OgMetaType` | | The type of meta tag to generate, such as `twitter:image`. When not specified, `og:image` will be used. | #### Template Values @@ -77,6 +78,7 @@ Use the `HCTI_Url_OgMetaTag` component to generate an OG meta tag with a URL-gen | Parameter | Required | Description | |----------------| :------: |-----------------------------------------------------------------------------------------------------------------------------------------| | `ImageRequest` | ✅ | An instance of [`HtmlCssToImage.Models.Requests.CreateUrlImageRequest`](../HtmlCssToImage/Models/Requests/CreateUrlImageRequest.cs) | +| `RenderOptions` | | Optional output format, sizing, DPI, and cropping options supplied as a `RenderImageOptions` instance. | | `OgMetaType` | | The type of meta tag to generate, such as `twitter:image`. When not specified, `og:image` will be used. | #### Image Request @@ -98,4 +100,4 @@ Check out the [Sample Project](https://github.com/htmlcsstoimage/dotnet-client/b > Check out the [HTML/CSS To Image Docs](https://docs.htmlcsstoimage.com) for more details on the API's capabilities. > [!TIP] -> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). \ No newline at end of file +> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). diff --git a/src/HtmlCssToImage.TagHelpers/HCTI_OgMetaTagHelperBase.cs b/src/HtmlCssToImage.TagHelpers/HCTI_OgMetaTagHelperBase.cs index 1c1c0a7..13dea5f 100644 --- a/src/HtmlCssToImage.TagHelpers/HCTI_OgMetaTagHelperBase.cs +++ b/src/HtmlCssToImage.TagHelpers/HCTI_OgMetaTagHelperBase.cs @@ -1,3 +1,4 @@ +using HtmlCssToImage.Models; using Microsoft.AspNetCore.Razor.TagHelpers; namespace HtmlCssToImage.TagHelpers; @@ -13,6 +14,8 @@ public HCTI_OgMetaTagHelperBase(IHtmlCssToImageClient htmlCssToImageClient) [HtmlAttributeName("og-meta-type")] public string? OgMetaType { get; set; } + [HtmlAttributeName("render-options")] public RenderImageOptions? RenderOptions { get; set; } + protected string? MetaUrl { get; set; } protected abstract void SetMetaUrl(); @@ -31,4 +34,4 @@ public override void Process(TagHelperContext context, TagHelperOutput output) output.Attributes.SetAttribute("content", MetaUrl); output.TagMode = TagMode.SelfClosing; } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.TagHelpers/HCTI_Templated_OgMetaTagHelper.cs b/src/HtmlCssToImage.TagHelpers/HCTI_Templated_OgMetaTagHelper.cs index de6577b..47b6d08 100644 --- a/src/HtmlCssToImage.TagHelpers/HCTI_Templated_OgMetaTagHelper.cs +++ b/src/HtmlCssToImage.TagHelpers/HCTI_Templated_OgMetaTagHelper.cs @@ -31,20 +31,46 @@ protected override void SetMetaUrl() { if (TemplateValues is JsonObject jo) { - MetaUrl= _htmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, jo, TemplateVersion); + MetaUrl = RenderOptions is null + ? _htmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, jo, TemplateVersion) + : _htmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + jo, + TemplateVersion, + RenderOptions); } else if(TemplateValues!=null) { if (JsonSerializerOptions != null) { - MetaUrl= _htmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, TemplateValues, JsonSerializerOptions, TemplateVersion); + MetaUrl = RenderOptions is null + ? _htmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + JsonSerializerOptions, + TemplateVersion) + : _htmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + JsonSerializerOptions, + TemplateVersion, + RenderOptions); } else { - MetaUrl= _htmlCssToImageClient.CreateTemplatedImageUrl(TemplateId, TemplateValues, TemplateVersion); + MetaUrl = RenderOptions is null + ? _htmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TemplateVersion) + : _htmlCssToImageClient.CreateTemplatedImageUrl( + TemplateId, + TemplateValues, + TemplateVersion, + RenderOptions); } } } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.TagHelpers/HCTI_Url_OgMetaTagHelper.cs b/src/HtmlCssToImage.TagHelpers/HCTI_Url_OgMetaTagHelper.cs index 87c5bb0..e83fdf0 100644 --- a/src/HtmlCssToImage.TagHelpers/HCTI_Url_OgMetaTagHelper.cs +++ b/src/HtmlCssToImage.TagHelpers/HCTI_Url_OgMetaTagHelper.cs @@ -20,7 +20,9 @@ public HCTI_Url_OgMetaTagHelper(IHtmlCssToImageClient htmlCssToImageClient) : ba protected override void SetMetaUrl() { - MetaUrl= _htmlCssToImageClient.CreateAndRenderUrl(ImageRequest); + MetaUrl = RenderOptions is null + ? _htmlCssToImageClient.CreateAndRenderUrl(ImageRequest) + : _htmlCssToImageClient.CreateAndRenderUrl(ImageRequest, RenderOptions); } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage.TagHelpers/README.md b/src/HtmlCssToImage.TagHelpers/README.md index f407287..98c8876 100644 --- a/src/HtmlCssToImage.TagHelpers/README.md +++ b/src/HtmlCssToImage.TagHelpers/README.md @@ -72,6 +72,7 @@ Use the `` tag to generate OG meta tags using a HCTI template | `template-values` | ✅ | A JSON-serializable object that will serve as the `template_values` in the request. | | `json-options` | | A `System.Text.Json.JsonSerializerOptions` instance that will be used to serialize `template-values` | | `template-version` | | An optional version of the template to use, if not specified, the latest version will be used | +| `render-options` | | Optional output format, sizing, DPI, and cropping options supplied as a `RenderImageOptions` instance. | | `og-meta-type` | | The type of meta tag to generate, such as `twitter:image`. When not specified, `og:image` will be used. | #### Template Values @@ -85,6 +86,7 @@ Use the `` tag to generate an OG meta tag with a URL-generating ima | Parameter | Required | Description | | --------- | :------: |-----------------------------------------------------------------------------------------------------------------------------------------| | `image-request` | ✅ | An instance of [`HtmlCssToImage.Models.Requests.CreateUrlImageRequest`](../HtmlCssToImage/Models/Requests/CreateUrlImageRequest.cs) | +| `render-options` | | Optional output format, sizing, DPI, and cropping options supplied as a `RenderImageOptions` instance. | | `og-meta-type` | | The type of meta tag to generate, such as `twitter:image`. When not specified, `og:image` will be used. | #### Image Request @@ -105,4 +107,4 @@ Check out the [Sample Project](https://github.com/htmlcsstoimage/dotnet-client/b > Check out the [HTML/CSS To Image Docs](https://docs.htmlcsstoimage.com) for more details on the API's capabilities. > [!TIP] -> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). \ No newline at end of file +> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.CreateAndRender.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.CreateAndRender.cs new file mode 100644 index 0000000..3fba2df --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.CreateAndRender.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; + +namespace HtmlCssToImage; + +public partial class HtmlCssToImageClient +{ + internal static void CreateAndRenderUrlQueryString(CreateUrlImageRequest request, ref UrlStringBuilder builder) + { + builder.EncodeSafeKey("url", request.Url); + + AppendBoolIfTrue("full_screen", request.FullScreen, ref builder); + AppendBoolIfTrue("block_consent_banners", request.BlockConsentBanners, ref builder); + AppendBoolIfTrue("disable_twemoji", request.DisableTwemoji, ref builder); + AppendBoolIfTrue("max_render_once", request.MaxRenderOnce, ref builder); + AppendBoolIfTrue("render_when_ready", request.RenderWhenReady, ref builder); + + if (request.ColorScheme != null) + { + builder.EncodeSafeKeyValue("color_scheme", request.ColorScheme.Value.ColorSchemeString()); + } + + AppendNumberIfNotNull("device_scale", request.DeviceScale, ref builder); + AppendNumberIfNotNull("max_wait_ms", request.MaxWaitMs, ref builder); + AppendNumberIfNotNull("ms_delay", request.MsDelay, ref builder); + AppendNumberIfNotNull("viewport_height", request.ViewportHeight, ref builder); + AppendNumberIfNotNull("viewport_width", request.ViewportWidth, ref builder); + + if (!string.IsNullOrWhiteSpace(request.Css)) + { + builder.EncodeSafeKey("css", request.Css); + } + + if (!string.IsNullOrWhiteSpace(request.Selector)) + { + builder.EncodeSafeKey("selector", request.Selector); + } + + if (!string.IsNullOrWhiteSpace(request.Timezone)) + { + builder.EncodeSafeKey("timezone", request.Timezone); + } + + AppendBoolIfTrue("viewport_mobile", request.ViewportMobile, ref builder); + AppendBoolIfTrue("viewport_landscape", request.ViewportLandscape, ref builder); + AppendBoolIfTrue("viewport_touch", request.ViewportTouch, ref builder); + if (request.MediaType != null) + { + builder.EncodeSafeKeyValue("media_type", request.MediaType.Value.MediaTypeString()); + } + + if (!string.IsNullOrWhiteSpace(request.ProxyId)) + { + builder.EncodeSafeKey("proxy_id", request.ProxyId); + } + + AppendNumberIfNotNull("jumbo_max_width", request.JumboMaxWidth, ref builder); + AppendNumberIfNotNull("jumbo_max_height", request.JumboMaxHeight, ref builder); + } + + private static void AppendNumberIfNotNull(ReadOnlySpan key, T? value, ref UrlStringBuilder builder) where T : struct, INumber + { + if (value != null) + { + builder.WriteSafeKey(key, value.Value); + } + } + + private static void AppendBoolIfTrue(ReadOnlySpan key, bool? value, ref UrlStringBuilder builder) + { + if (value == true) + { + builder.EncodeSafeKeyValue(key, "true"); + } + } + + /// + public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageFormat format = RenderImageFormat.PNG) + { + var pathFormat = format == RenderImageFormat.PNG ? null : (RenderImageFormat?)format; + return CreateAndRenderUrl(request, pathFormat, null); + } + + /// + public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return CreateAndRenderUrl(request, options.Format, options); + } + + private string CreateAndRenderUrl( + CreateUrlImageRequest request, + RenderImageFormat? pathFormat, + RenderImageOptions? options) + { + UrlStringBuilder builder = new(stackalloc char[512]); + try + { + builder.AppendLiteral(_apiHost); + builder.AppendLiteral(CREATE_AND_RENDER_PATH); + builder.AppendLiteral('/'); + builder.AppendLiteral(_apiId); + builder.AppendLiteral('/'); + var tokenPosition = builder.ReserveLiteral(HmacToken.TokenLength); + + if (pathFormat.HasValue) + { + builder.AppendLiteral('/'); + builder.AppendLiteral(pathFormat.Value.RenderFormatToExtensionWithoutDot()); + } + + CreateAndRenderUrlQueryString(request, ref builder); + options?.AppendToBuilder(ref builder, includeFormat: false); + + var queryString = builder.QueryString(false); + HmacToken.WriteToken( + queryString, + _apiKey, + builder.ReservedLiteral(tokenPosition, HmacToken.TokenLength)); + + return builder.FullSpan.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// + public string ImageUrl(string imageId, RenderImageOptions options) => + RenderImageOptions.ToUrl(_apiHost, imageId, options); +} diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.Templated.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.Templated.cs new file mode 100644 index 0000000..f0ca218 --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.Templated.cs @@ -0,0 +1,205 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; + +namespace HtmlCssToImage; + +public partial class HtmlCssToImageClient +{ + /// + public string CreateTemplatedImageUrl(string templateId, T templateValues, + JsonTypeInfo typeInfo, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => + CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues, typeInfo), + templateVersion, + format); + + /// + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + JsonTypeInfo typeInfo, + long? templateVersion, + RenderImageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues, typeInfo), + templateVersion, + options); + } + + /// + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + public string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => + CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues), + templateVersion, + format); + + /// + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + long? templateVersion, + RenderImageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues), + templateVersion, + options); + } + + /// + public string CreateTemplatedImageUrl(string templateId, T templateValues, + JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => + CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues, jsonSerializerOptions), + templateVersion, + format); + + /// + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + JsonSerializerOptions jsonSerializerOptions, + long? templateVersion, + RenderImageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return CreateTemplatedImageUrl( + templateId, + SerializeTemplateValues(templateValues, jsonSerializerOptions), + templateVersion, + options); + } + + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + private static JsonObject SerializeTemplateValues(T templateValues) + { + return RequireJsonObject(JsonSerializer.SerializeToNode(templateValues)); + } + + private static JsonObject SerializeTemplateValues(T templateValues, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return RequireJsonObject(JsonSerializer.SerializeToNode(templateValues, typeInfo)); + } + + private static JsonObject SerializeTemplateValues( + T templateValues, + JsonSerializerOptions jsonSerializerOptions) + { + ArgumentNullException.ThrowIfNull(jsonSerializerOptions); + return RequireJsonObject( + JsonSerializer.SerializeToNode( + templateValues, + jsonSerializerOptions.GetTypeInfo(typeof(T)))); + } + + private static JsonObject RequireJsonObject(JsonNode? serializedValues) + { + if (serializedValues is not JsonObject jsonObject) + { + throw new ArgumentException("Template values must serialize to a JSON object."); + } + + return jsonObject; + } + + /// + public string CreateTemplatedImageUrl(string templateId, JsonObject templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) + { + var pathFormat = format == RenderImageFormat.PNG ? null : (RenderImageFormat?)format; + return CreateTemplatedImageUrl(templateId, templateValues, templateVersion, pathFormat, null); + } + + /// + public string CreateTemplatedImageUrl( + string templateId, + JsonObject templateValues, + long? templateVersion, + RenderImageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return CreateTemplatedImageUrl( + templateId, + templateValues, + templateVersion, + options.Format, + options); + } + + private string CreateTemplatedImageUrl( + string templateId, + JsonObject templateValues, + long? templateVersion, + RenderImageFormat? pathFormat, + RenderImageOptions? options) + { + UrlStringBuilder builder = new(stackalloc char[512]); + try + { + builder.AppendLiteral(_apiHost); + builder.AppendLiteral(CREATE_OR_GET_PATH); + builder.AppendLiteral('/'); + builder.AppendLiteral(templateId); + builder.AppendLiteral('/'); + var tokenPosition = builder.ReserveLiteral(HmacToken.TokenLength); + + if (pathFormat.HasValue) + { + builder.AppendLiteral('/'); + builder.AppendLiteral(pathFormat.Value.RenderFormatToExtensionWithoutDot()); + } + + if (templateVersion.HasValue) + { + builder.WriteSafeKey(TEMPLATE_VERSION_QUERY_PARAM, templateVersion.Value); + } + + foreach (var (key, value) in templateValues.OrderBy(x => x.Key)) + { + if (value is not null) + { + builder.Encode(key, value.ToJsonString()); + } + } + + options?.AppendToBuilder( + ref builder, + includeFormat: false, + templateValues: templateValues); + + var queryString = builder.QueryString(false); + HmacToken.WriteToken( + queryString, + _apiKey, + builder.ReservedLiteral(tokenPosition, HmacToken.TokenLength)); + + if (queryString.IsEmpty) + { + builder.AppendLiteral('?'); + } + + return builder.FullSpan.ToString(); + } + finally + { + builder.Dispose(); + } + } +} diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs deleted file mode 100644 index c0c7da8..0000000 --- a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System.Buffers; -using System.Diagnostics.CodeAnalysis; -using System.Numerics; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; -using HtmlCssToImage.Helpers; -using HtmlCssToImage.Models; -using HtmlCssToImage.Models.Requests; - -namespace HtmlCssToImage; - -public partial class HtmlCssToImageClient -{ - /// - public string CreateTemplatedImageUrl(string templateId, T templateValues, - JsonTypeInfo typeInfo, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => CreateTemplatedImageUrl(templateId, templateValues, templateVersion, typeInfo, null, format); - - /// - [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] - [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] - public string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => - CreateTemplateImageUrlNoTypeInfo(templateId, templateValues, templateVersion, format); - - /// - public string CreateTemplatedImageUrl(string templateId, T templateValues, - JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => - CreateTemplatedImageUrl(templateId, templateValues, templateVersion, null, jsonSerializerOptions, format); - - [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] - [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] - private string CreateTemplateImageUrlNoTypeInfo(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) - { - var serialized_values = JsonSerializer.SerializeToNode(templateValues); - if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) - { - throw new ArgumentException("Invalid parameter values"); - } - - return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); - } - - private string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, - JsonTypeInfo? typeInfo = null, JsonSerializerOptions? jsonSerializerOptions = null, RenderImageFormat format = RenderImageFormat.PNG) - { - JsonNode? serialized_values; - if (typeInfo != null) - { - serialized_values = JsonSerializer.SerializeToNode(templateValues, typeInfo); - } - else if (jsonSerializerOptions != null) - { - serialized_values = JsonSerializer.SerializeToNode(templateValues, jsonSerializerOptions.GetTypeInfo(typeof(T))); - } - else - { - throw new ArgumentException("Must provide either typeInfo or jsonSerializerOptions"); - } - - if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) - { - throw new ArgumentException("Invalid parameter values"); - } - - return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); - } - - /// - public string CreateTemplatedImageUrl(string templateId, JsonObject templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) - { - ArrayOrSpan chars = new(stackalloc char[512]); - try - { - if (templateVersion.HasValue) - { - QueryStringEncoder.WriteSafeKey(TEMPLATE_VERSION_QUERY_PARAM, templateVersion.Value, ref chars); - } - - foreach (var (key, value) in templateValues.OrderBy(x => x.Key)) - { - if (value is not null) - { - QueryStringEncoder.Encode(key, value.ToJsonString(), ref chars); - } - } - - var token = HmacToken.CreateToken(chars.LimitedSpan, _apiKey); - var format_string = string.Empty; - if (format != RenderImageFormat.PNG) - { - format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; - } - - var url = $"{_apiHost}{CREATE_PATH}/{templateId}/{token}{format_string}?{chars.LimitedSpan}"; - - return url; - } - finally - { - chars.Dispose(); - } - - } - - internal static void CreateAndRenderUrlQueryString(CreateUrlImageRequest request, ref ArrayOrSpan chars) - { - QueryStringEncoder.EncodeSafeKey("url", request.Url, ref chars); - - AppendBoolIfTrue("full_screen",request.FullScreen, ref chars); - AppendBoolIfTrue("block_consent_banners",request.BlockConsentBanners, ref chars); - AppendBoolIfTrue("disable_twemoji",request.DisableTwemoji, ref chars); - AppendBoolIfTrue("max_render_once",request.MaxRenderOnce, ref chars); - AppendBoolIfTrue("render_when_ready",request.RenderWhenReady, ref chars); - - if (request.ColorScheme != null) - { - QueryStringEncoder.EncodeSafeKeyValue("color_scheme", request.ColorScheme.Value.ColorSchemeString(), ref chars); - } - AppendNumberIfNotNull("device_scale", request.DeviceScale, ref chars); - AppendNumberIfNotNull("max_wait_ms", request.MaxWaitMs, ref chars); - AppendNumberIfNotNull("ms_delay", request.MsDelay, ref chars); - AppendNumberIfNotNull("viewport_height", request.ViewportHeight, ref chars); - AppendNumberIfNotNull("viewport_width", request.ViewportWidth, ref chars); - - if (!string.IsNullOrWhiteSpace(request.Css)) - { - QueryStringEncoder.EncodeSafeKey("css", request.Css, ref chars); - } - - if (!string.IsNullOrWhiteSpace(request.Selector)) - { - QueryStringEncoder.EncodeSafeKey("selector", request.Selector, ref chars); - } - - if (!string.IsNullOrWhiteSpace(request.Timezone)) - { - QueryStringEncoder.EncodeSafeKey("timezone", request.Timezone, ref chars); - } - AppendBoolIfTrue("viewport_mobile", request.ViewportMobile, ref chars); - AppendBoolIfTrue("viewport_landscape", request.ViewportLandscape, ref chars); - AppendBoolIfTrue("viewport_touch", request.ViewportTouch, ref chars); - if (request.MediaType != null) - { - QueryStringEncoder.EncodeSafeKeyValue("media_type", request.MediaType.Value.MediaTypeString(), ref chars); - } - - if (!string.IsNullOrWhiteSpace(request.ProxyId)) - { - QueryStringEncoder.EncodeSafeKey("proxy_id", request.ProxyId, ref chars); - } - - AppendNumberIfNotNull("jumbo_max_width", request.JumboMaxWidth, ref chars); - AppendNumberIfNotNull("jumbo_max_height", request.JumboMaxHeight, ref chars); - - } - - private static void AppendNumberIfNotNull(ReadOnlySpan key, T? value, ref ArrayOrSpan chars) where T : struct, INumber - { - if (value != null) - { - QueryStringEncoder.WriteSafeKey(key, value.Value, ref chars); - } - } - - private static void AppendBoolIfTrue(ReadOnlySpan key, bool? value, ref ArrayOrSpan chars) - { - if (value == true) - { - QueryStringEncoder.EncodeSafeKeyValue(key, "true", ref chars); - } - } - - /// - public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageFormat format = RenderImageFormat.PNG) - { - - ArrayOrSpan chars = new(stackalloc char[512]); - try - { - CreateAndRenderUrlQueryString(request, ref chars); - var token = HmacToken.CreateToken(chars.LimitedSpan, _apiKey); - - var format_string = string.Empty; - if (format != RenderImageFormat.PNG) - { - format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; - } - - var url = $"{_apiHost}{CREATE_AND_RENDER_PATH}/{_apiId}/{token}{format_string}?{chars.LimitedSpan}"; - - return url; - } - finally - { - chars.Dispose(); - } - - } -} \ No newline at end of file diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs index d286802..a930bad 100644 --- a/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs @@ -36,10 +36,10 @@ internal HtmlCssToImageClient(HttpClient client, HtmlCssToImageOptions options, } private const string DEFAULT_HOST = "https://hcti.io"; - private const string CREATE_PATH = "/v1/image"; - private const string CREATE_AND_RENDER_PATH = $"{CREATE_PATH}/create-and-render"; - private const string CREATE_URL = $"{CREATE_PATH}?includeId=true"; - private const string CREATE_BATCH_URL = $"{CREATE_PATH}/batch"; + internal const string CREATE_OR_GET_PATH = "/v1/image"; + private const string CREATE_AND_RENDER_PATH = $"{CREATE_OR_GET_PATH}/create-and-render"; + private const string CREATE_URL = $"{CREATE_OR_GET_PATH}?includeId=true"; + private const string CREATE_BATCH_URL = $"{CREATE_OR_GET_PATH}/batch"; private const string TEMPLATE_VERSION_QUERY_PARAM = "template_version"; -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs b/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs index fc063b8..7d8a585 100644 --- a/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs +++ b/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs @@ -34,11 +34,13 @@ public ArrayOrSpan(int size) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() { - if (_rented != null) + if (_rented == null) { - ArrayPool.Shared.Return(_rented); - _rented = null; + return; } + + ArrayPool.Shared.Return(_rented); + _rented = null; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -50,21 +52,29 @@ public void Advance(int count) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void EnsureCapacity(int additionalCapacity) { - int requiredCapacity = Position + additionalCapacity; - if (requiredCapacity > Span.Length) + EnsureCapacity(Position, additionalCapacity); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EnsureCapacity(int position, int additionalCapacity) + { + var requiredCapacity = position + additionalCapacity; + if (requiredCapacity <= Span.Length) { - var newSize = requiredCapacity + 128; - var newRented = ArrayPool.Shared.Rent(newSize); + return; + } - Span[..Position].CopyTo(newRented); + var newSize = requiredCapacity + 128; + var newRented = ArrayPool.Shared.Rent(newSize); - if (_rented != null) - { - ArrayPool.Shared.Return(_rented); - } + Span[..position].CopyTo(newRented); - _rented = newRented; - Span = _rented; + if (_rented != null) + { + ArrayPool.Shared.Return(_rented); } + + _rented = newRented; + Span = _rented; } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage/Helpers/CropHelpers.cs b/src/HtmlCssToImage/Helpers/CropHelpers.cs new file mode 100644 index 0000000..49d08b3 --- /dev/null +++ b/src/HtmlCssToImage/Helpers/CropHelpers.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +internal static class CropHelpers +{ + public static void Validate(this RenderImageCropOrigin origin, string parameterName) + { + if (origin is < RenderImageCropOrigin.Start or > RenderImageCropOrigin.End) + { + throw new ArgumentOutOfRangeException(parameterName, origin, "Unknown crop origin."); + } + } + + public static void AppendToQueryString( + this RenderImageCropOrigin origin, + ReadOnlySpan key, + ref UrlStringBuilder chars) + { + ReadOnlySpan value = origin switch + { + RenderImageCropOrigin.Start => "start", + RenderImageCropOrigin.Center => "center", + RenderImageCropOrigin.End => "end", + _ => throw new ArgumentOutOfRangeException(nameof(origin), origin, "Unknown crop origin.") + }; + + chars.EncodeSafeKeyValue(key, value); + } + + public static bool TryParsePosition( + string? input, + IFormatProvider? provider, + out int value, + out RenderImageCropUnit unit) + { + value = default; + unit = default; + + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var span = input.AsSpan().Trim(); + if (!TryReadUnit(ref span, out unit)) + { + return false; + } + + return int.TryParse(span, provider, out value); + } + + public static bool TryParseSize( + string? input, + IFormatProvider? provider, + out uint value, + out RenderImageCropUnit unit) + { + value = default; + unit = default; + + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var span = input.AsSpan().Trim(); + if (!TryReadUnit(ref span, out unit)) + { + return false; + } + + return uint.TryParse(span, provider, out value); + } + + public static bool IsValidPosition(int value, RenderImageCropUnit unit) + { + return unit switch + { + RenderImageCropUnit.Pixels => value >= 0, + RenderImageCropUnit.Percent => value is > 0 and <= 100, + _ => false + }; + } + + public static bool IsValidSize(uint value, RenderImageCropUnit unit) + { + return unit switch + { + RenderImageCropUnit.Pixels => value > 0, + RenderImageCropUnit.Percent => value is > 0 and <= 100, + _ => false + }; + } + + public static void ValidatePosition(int value, RenderImageCropUnit unit, string parameterName) + { + if (!IsValidPosition(value, unit)) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + unit == RenderImageCropUnit.Percent + ? "Percentage positions must be greater than zero and no greater than 100." + : "Pixel positions must be non-negative."); + } + } + + public static void ValidateSize(uint value, RenderImageCropUnit unit, string parameterName) + { + if (!IsValidSize(value, unit)) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + unit == RenderImageCropUnit.Percent + ? "Percentage sizes must be greater than zero and no greater than 100." + : "Pixel sizes must be greater than zero."); + } + } + + public static void AppendToQueryString( + T value, + RenderImageCropUnit unit, + ReadOnlySpan key, + ref UrlStringBuilder chars) + where T : struct, ISpanFormattable + { + Span formatted = stackalloc char[16]; + if (!value.TryFormat(formatted, out var written, default, CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException("Unable to format a crop measurement."); + } + + if (unit == RenderImageCropUnit.Percent) + { + formatted[written++] = '%'; + } + else + { + formatted[written++] = 'p'; + formatted[written++] = 'x'; + } + + chars.EncodeSafeKey(key, formatted[..written]); + } + + private static bool TryReadUnit(ref ReadOnlySpan span, out RenderImageCropUnit unit) + { + if (span.EndsWith("%", StringComparison.Ordinal)) + { + unit = RenderImageCropUnit.Percent; + span = span[..^1].TrimEnd(); + } + else if (span.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + unit = RenderImageCropUnit.Pixels; + span = span[..^2].TrimEnd(); + } + else + { + unit = RenderImageCropUnit.Pixels; + } + + return !span.IsEmpty; + } +} diff --git a/src/HtmlCssToImage/Helpers/HmacToken.cs b/src/HtmlCssToImage/Helpers/HmacToken.cs index fdbf619..18582ef 100644 --- a/src/HtmlCssToImage/Helpers/HmacToken.cs +++ b/src/HtmlCssToImage/Helpers/HmacToken.cs @@ -7,20 +7,40 @@ namespace HtmlCssToImage.Helpers; internal static class HmacToken { + internal const int TokenLength = 64; + internal static string CreateToken(in ReadOnlySpan message, in ReadOnlySpan secret) { - var max_b_message = Encoding.UTF8.GetMaxByteCount(message.Length); - var max_b_secret = Encoding.UTF8.GetMaxByteCount(secret.Length); + Span token = stackalloc char[TokenLength]; + WriteToken(message, secret, token); + return token.ToString(); + } + + internal static void WriteToken(in ReadOnlySpan message, in ReadOnlySpan secret, Span destination) + { + var maximumMessageByteCount = Encoding.UTF8.GetMaxByteCount(message.Length); + var maximumSecretByteCount = Encoding.UTF8.GetMaxByteCount(secret.Length); + + using ArrayOrSpan messageBuffer = maximumMessageByteCount <= 256 + ? new(stackalloc byte[256]) + : new(maximumMessageByteCount); + using ArrayOrSpan secretBuffer = maximumSecretByteCount <= 256 + ? new(stackalloc byte[256]) + : new(maximumSecretByteCount); - using ArrayOrSpan msg_buffer = max_b_message<=256? new(stackalloc byte[256]): new(max_b_message); - using ArrayOrSpan secret_buffer = max_b_secret<=256? new(stackalloc byte[256]): new(max_b_secret); + Span hash = stackalloc byte[32]; + var messageBytesWritten = Encoding.UTF8.GetBytes(message, messageBuffer.Span); + var secretBytesWritten = Encoding.UTF8.GetBytes(secret, secretBuffer.Span); + HMACSHA256.HashData( + secretBuffer.Span[..secretBytesWritten], + messageBuffer.Span[..messageBytesWritten], + hash); - Span hashed_b = stackalloc byte[32]; - var msg_bw = Encoding.UTF8.GetBytes(message, msg_buffer.Span); - var sec_bw = Encoding.UTF8.GetBytes(secret, secret_buffer.Span); - HMACSHA256.HashData(secret_buffer.Span.Slice(0, sec_bw), msg_buffer.Span.Slice(0, msg_bw), hashed_b); - var final = Convert.ToHexStringLower(hashed_b); - return final; + if (!Convert.TryToHexStringLower(hash, destination, out var charsWritten) + || charsWritten != TokenLength) + { + throw new InvalidOperationException("Could not format the HMAC token."); + } } -} \ No newline at end of file +} diff --git a/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs b/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs index 2a440f5..4c3d07e 100644 --- a/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs +++ b/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs @@ -1,5 +1,4 @@ using System.Buffers; -using System.Globalization; using System.Numerics; using System.Runtime.CompilerServices; using System.Text; @@ -8,72 +7,11 @@ namespace HtmlCssToImage.Helpers; internal static class QueryStringEncoder { - private static readonly SearchValues urlSafeChars = SearchValues.Create( + private static readonly SearchValues UrlSafeChars = SearchValues.Create( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-.~"); - public static void Write(string key, T value, ref ArrayOrSpan buffer) where T : INumber - { - var max_key_length = GetEncodedLength(key.AsSpan(), out var key_needs_encoding); - - var value_length_max = GetMaxChars(); - - var required = max_key_length + value_length_max + 1 + (buffer.Position > 0 ? 1 : 0); - - buffer.EnsureCapacity(required); - - if (buffer.Position > 0) - { - buffer.Span[buffer.Position++] = '&'; - } - - - if (!key_needs_encoding) - { - key.AsSpan().CopyTo(buffer.RemainingSpan); - buffer.Advance(key.Length); - } - else - { - buffer.Advance(EncodeCore(key, buffer.RemainingSpan)); - } - - buffer.Span[buffer.Position++] = '='; - if (!value.TryFormat(buffer.RemainingSpan, out var chars_written, "R", CultureInfo.InvariantCulture)) - { - throw new InvalidOperationException($"Could not format value {value} into query string for {key}"); - } - buffer.Advance(chars_written); - } - - public static void WriteSafeKey(ReadOnlySpan key, T value, ref ArrayOrSpan buffer) where T : INumber - { - var value_length_max = GetMaxChars(); - - var required = key.Length + value_length_max + 1 + (buffer.Position > 0 ? 1 : 0); - - buffer.EnsureCapacity(required); - - var span = buffer.Span; - var pos = buffer.Position; - if (pos > 0) - { - span[pos++] = '&'; - } - - key.CopyTo(span[pos..]); - pos+=key.Length; - - span[pos++] = '='; - if (!value.TryFormat(span[pos..], out var chars_written, "R", CultureInfo.InvariantCulture)) - { - throw new InvalidOperationException($"Could not format value {value} into query string for {key}"); - } - buffer.Position = pos+chars_written; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetMaxChars() + internal static int GetMaxChars() where T : INumber { if (typeof(T) == typeof(int)) return 11; @@ -87,207 +25,88 @@ private static int GetMaxChars() return 32; // fallback } - public static void EncodeSafeKey(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) - { - var max_value_length = GetEncodedLength(value, out var value_needs_encoding); - - var required = key.Length + max_value_length + 1 + (buffer.Position > 0 ? 1 : 0); - buffer.EnsureCapacity(required); - - var span = buffer.Span; - var pos = buffer.Position; - - if (pos > 0) - { - span[pos++] = '&'; - } - key.CopyTo(span[pos..]); - pos+=key.Length; - span[pos++] = '='; - - if (!value_needs_encoding) - { - value.CopyTo(span[pos..]); - pos+=value.Length; - } - else - { - pos += EncodeCore(value, span[pos..]); - } - buffer.Position = pos; - } - - public static void EncodeSafeKeyValue(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) - { - - var required = key.Length + value.Length + 1 + (buffer.Position > 0 ? 1 : 0); - buffer.EnsureCapacity(required); - var span = buffer.Span; - var pos = buffer.Position; - if (pos > 0) - { - span[pos++] = '&'; - } - - key.CopyTo(span[pos..]); - pos+=key.Length; - span[pos++] = '='; - - value.CopyTo(span[pos..]); - buffer.Position = pos+value.Length; - } - - public static void Encode(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) - { - var max_key_length = GetEncodedLength(key, out var key_needs_encoding); - var max_value_length = GetEncodedLength(value, out var value_needs_encoding); - - var required = max_key_length + max_value_length + 1 + (buffer.Position > 0 ? 1 : 0); - - buffer.EnsureCapacity(required); - var span = buffer.Span; - var pos = buffer.Position; - if (pos > 0) - { - span[pos++] = '&'; - } - - if (!key_needs_encoding) - { - key.CopyTo(span[pos..]); - pos += key.Length; - } - else - { - pos += EncodeCore(key, span[pos..]); - } - - - span[pos++] = '='; - - if (!value_needs_encoding) - { - value.CopyTo(span[pos..]); - pos += value.Length; - } - else - { - pos += EncodeCore(value, span[pos..]); - } - - buffer.Position = pos; - } - - private static int EncodeCore(ReadOnlySpan input, Span destination) + internal static int EncodeCore( + scoped ReadOnlySpan input, + ref ArrayOrSpan destination, + int position) { - int written = 0; - Span utf8 = stackalloc byte[4]; - int i = 0; - while (i < input.Length) + var output = destination.Span; + + while (!input.IsEmpty) { - int safeLength = input[i..].IndexOfAnyExcept(urlSafeChars); + var safeLength = input.IndexOfAnyExcept(UrlSafeChars); if (safeLength == -1) { - input[i..].CopyTo(destination[written..]); - written += input.Length - i; - break; + if (position + input.Length > output.Length) + { + destination.EnsureCapacity(position, input.Length); + output = destination.Span; + } + + input.CopyTo(output[position..]); + return position + input.Length; } if (safeLength > 0) { - input[i..(i + safeLength)].CopyTo(destination[written..]); - written += safeLength; - i += safeLength; - } + if (position + safeLength > output.Length) + { + destination.EnsureCapacity(position, safeLength); + output = destination.Span; + } - var rune_status = Rune.DecodeFromUtf16(input[i..], out Rune rune, out int charsConsumed); - if (rune_status != OperationStatus.Done) - { - i++; // INVALID!? - continue; + input[..safeLength].CopyTo(output[position..]); + position += safeLength; + input = input[safeLength..]; } - if (rune.IsAscii && urlSafeChars.Contains((char)rune.Value)) - { - destination[written++] = (char)rune.Value; - } - else + var currentChar = input[0]; + if (char.IsAscii(currentChar)) { - var utf8_len = rune.EncodeToUtf8(utf8); - for (var utf8_b = 0; utf8_b < utf8_len; utf8_b++) + if (position + 3 > output.Length) { - var this_b = utf8[utf8_b]; - destination[written++] = '%'; - destination[written++] = GetHexValue(this_b >> 4); - destination[written++] = GetHexValue(this_b & 0xF); + destination.EnsureCapacity(position, 3); + output = destination.Span; } - } - - i += charsConsumed; - } - return written; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static char GetHexValue(int i) => (char)(i < 10 ? i + '0' : i - 10 + 'A'); - - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetEncodedLength(ReadOnlySpan span, out bool needsEncoding) - { - int firstUnsafeIndex = span.IndexOfAnyExcept(urlSafeChars); - if (firstUnsafeIndex == -1) - { - needsEncoding = false; - return span.Length; - } - - needsEncoding = true; - int length = firstUnsafeIndex; - int i = firstUnsafeIndex; - - while (i < span.Length) - { - int safeLength = span[i..].IndexOfAnyExcept(urlSafeChars); - if (safeLength == -1) - { - length += span.Length - i; - break; - } - if (safeLength > 0) - { - length += safeLength; - i += safeLength; - } - - char c = span[i]; - if (c <= 0x7F) - { - length += 3; - i++; + output[position++] = '%'; + output[position++] = GetHexValue(currentChar >> 4); + output[position++] = GetHexValue(currentChar & 0xF); + input = input[1..]; continue; } - if (!char.IsSurrogate(c)) + var runeStatus = Rune.DecodeFromUtf16(input, out var rune, out var charsConsumed); + if (runeStatus != OperationStatus.Done) { - length += c <= 0x7FF ? 6 : 9; - i++; + input = input[1..]; continue; } + input = input[charsConsumed..]; - var status = Rune.DecodeFromUtf16(span[i..], out _, out int charsConsumed); - if (status != OperationStatus.Done) + var utf8Length = rune.EncodeToUtf8(utf8); + var required = utf8Length * 3; + if (position + required > output.Length) { - i++; - continue; + destination.EnsureCapacity(position, required); + output = destination.Span; } - length += 12; - i += charsConsumed; + for (var utf8Index = 0; utf8Index < utf8Length; utf8Index++) + { + var currentByte = utf8[utf8Index]; + output[position++] = '%'; + output[position++] = GetHexValue(currentByte >> 4); + output[position++] = GetHexValue(currentByte & 0xF); + } } - return length; + + return position; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static char GetHexValue(int i) => (char)(i < 10 ? i + '0' : i - 10 + 'A'); + } diff --git a/src/HtmlCssToImage/Helpers/UrlStringBuilder.cs b/src/HtmlCssToImage/Helpers/UrlStringBuilder.cs new file mode 100644 index 0000000..d493fd4 --- /dev/null +++ b/src/HtmlCssToImage/Helpers/UrlStringBuilder.cs @@ -0,0 +1,155 @@ +using System.Globalization; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace HtmlCssToImage.Helpers; + +internal ref struct UrlStringBuilder : IDisposable +{ + private ArrayOrSpan _chars; + private int _parameterCount; + private int _questionMarkPosition; + + public UrlStringBuilder(Span initial) + { + _chars = new(initial); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(ReadOnlySpan value) + { + _chars.EnsureCapacity(value.Length); + value.CopyTo(_chars.RemainingSpan); + _chars.Advance(value.Length); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(char value) + { + _chars.EnsureCapacity(1); + _chars.RemainingSpan[0] = value; + _chars.Advance(1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int ReserveLiteral(int length) + { + _chars.EnsureCapacity(length); + var position = _chars.Position; + _chars.Advance(length); + return position; + } + + public Span ReservedLiteral(int position, int length) + { + return _chars.Span.Slice(position, length); + } + + public ReadOnlySpan FullSpan => _chars.LimitedSpan; + + public ReadOnlySpan QueryString(bool includeQuestionMark) + { + if (_parameterCount == 0) + { + return ReadOnlySpan.Empty; + } + + var starter = includeQuestionMark ? _questionMarkPosition : _questionMarkPosition + 1; + return _chars.LimitedSpan[starter..]; + } + + public void Encode(ReadOnlySpan key, ReadOnlySpan value) + { + var required = key.Length + value.Length + 2; + _chars.EnsureCapacity(required); + var span = _chars.Span; + var position = AppendQOrAmp(span, _chars.Position); + + position = QueryStringEncoder.EncodeCore(key, ref _chars, position); + span = _chars.Span; + span[position++] = '='; + position = QueryStringEncoder.EncodeCore(value, ref _chars, position); + + _chars.Position = position; + _parameterCount++; + } + + public void EncodeSafeKeyValue(ReadOnlySpan key, scoped ReadOnlySpan value) + { + var required = key.Length + value.Length + 2; + _chars.EnsureCapacity(required); + var span = _chars.Span; + var position = AppendQOrAmp(span, _chars.Position); + + key.CopyTo(span[position..]); + position += key.Length; + span[position++] = '='; + + value.CopyTo(span[position..]); + position += value.Length; + + _chars.Position = position; + _parameterCount++; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EncodeSafeKey(ReadOnlySpan key, scoped ReadOnlySpan value) + { + var required = key.Length + value.Length + 2; + _chars.EnsureCapacity(required); + var span = _chars.Span; + var position = AppendQOrAmp(span, _chars.Position); + + key.CopyTo(span[position..]); + position += key.Length; + span[position++] = '='; + position = QueryStringEncoder.EncodeCore(value, ref _chars, position); + + _chars.Position = position; + _parameterCount++; + } + + public void WriteSafeKey(ReadOnlySpan key, T value) where T : INumber + { + var maximumValueLength = QueryStringEncoder.GetMaxChars(); + var required = key.Length + maximumValueLength + 2; + + _chars.EnsureCapacity(required); + var span = _chars.Span; + var position = AppendQOrAmp(span, _chars.Position); + + key.CopyTo(span[position..]); + position += key.Length; + span[position++] = '='; + + if (!value.TryFormat(span[position..], out var charsWritten, "R", CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException($"Could not format value {value} into query string for {key}"); + } + + position += charsWritten; + _chars.Position = position; + _parameterCount++; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int AppendQOrAmp(Span span, int position) + { + if (_parameterCount == 0) + { + _questionMarkPosition = position; + span[position++] = '?'; + } + else + { + span[position++] = '&'; + } + + return position; + } + + public void Dispose() + { + _chars.Dispose(); + } +} diff --git a/src/HtmlCssToImage/IHtmlCssToImageClient.cs b/src/HtmlCssToImage/IHtmlCssToImageClient.cs index 51f6bd4..03f159b 100644 --- a/src/HtmlCssToImage/IHtmlCssToImageClient.cs +++ b/src/HtmlCssToImage/IHtmlCssToImageClient.cs @@ -60,6 +60,18 @@ public interface IHtmlCssToImageClient /// public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageFormat format = RenderImageFormat.PNG); + /// + /// Generates a URL for creating and rendering an image with output sizing and cropping options. + /// + /// + /// The containing the source URL and browser rendering parameters. + /// + /// + /// The output format, dimensions, DPI, and cropping options applied to the rendered image. + /// + /// A signed URL for the image rendering request. + public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageOptions options); + /// /// Generates a URL for creating an image using a predefined template, with customizable template values, /// optional template version, and specified output format. @@ -81,6 +93,23 @@ public interface IHtmlCssToImageClient /// public string CreateTemplatedImageUrl(string templateId, JsonObject templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG); + /// + /// Generates a templated image URL with output sizing and cropping options. + /// + /// The identifier of the template to render. + /// The values substituted into the template. + /// The template version, or for the latest version. + /// + /// The output format, dimensions, DPI, and cropping options applied to the rendered image. + /// Render-option query keys are disambiguated when a template value uses the same key. + /// + /// A signed URL for the templated image. + public string CreateTemplatedImageUrl( + string templateId, + JsonObject templateValues, + long? templateVersion, + RenderImageOptions options); + /// /// Generates a templated image URL based on the specified template identifier and template values. /// @@ -93,6 +122,21 @@ public interface IHtmlCssToImageClient [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] public string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG); + /// + /// Generates a templated image URL from an object with output sizing and cropping options. + /// + /// The identifier of the template to render. + /// The object containing values substituted into the template. + /// The template version, or for the latest version. + /// The output format, dimensions, DPI, and cropping options. + /// A signed URL for the templated image. + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + long? templateVersion, + RenderImageOptions options); /// /// Generates a templated image URL based on the specified template identifier and template values. @@ -105,6 +149,22 @@ public interface IHtmlCssToImageClient /// A string containing the URL of the generated templated image. public string CreateTemplatedImageUrl(string templateId, T templateValues, JsonTypeInfo typeInfo, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG); + /// + /// Generates a templated image URL using source-generated JSON metadata and output options. + /// + /// The identifier of the template to render. + /// The object containing values substituted into the template. + /// Source-generated JSON metadata for . + /// The template version, or for the latest version. + /// The output format, dimensions, DPI, and cropping options. + /// A signed URL for the templated image. + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + JsonTypeInfo typeInfo, + long? templateVersion, + RenderImageOptions options); + /// /// Generates a URL for a templated image based on the provided template identifier, template values, and optional parameters. /// @@ -116,6 +176,21 @@ public interface IHtmlCssToImageClient /// A string containing the URL of the generated templated image. public string CreateTemplatedImageUrl(string templateId, T templateValues, JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG); + /// + /// Generates a templated image URL using the supplied JSON serializer configuration and output options. + /// + /// The identifier of the template to render. + /// The object containing values substituted into the template. + /// The JSON serializer configuration for . + /// The template version, or for the latest version. + /// The output format, dimensions, DPI, and cropping options. + /// A signed URL for the templated image. + public string CreateTemplatedImageUrl( + string templateId, + T templateValues, + JsonSerializerOptions jsonSerializerOptions, + long? templateVersion, + RenderImageOptions options); /// /// Creates a new version of a template using the specified template ID and request parameters. @@ -156,4 +231,11 @@ public interface IHtmlCssToImageClient public Task?>> ListTemplatesAsync(int count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default); -} \ No newline at end of file + /// + /// Generates a URL for an image based on the specified image ID and rendering options. + /// + /// The unique identifier of the image for which the URL is to be generated. + /// The rendering options, such as format, DPI, or height, that define how the image should be processed and displayed. + /// A string representing the generated URL for accessing the specified image with the provided rendering options. + public string ImageUrl(string imageId, RenderImageOptions options); +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageAspectRatio.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageAspectRatio.cs new file mode 100644 index 0000000..5fdc65e --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageAspectRatio.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +/// +/// Defines a positive width-to-height aspect ratio. +/// +public readonly record struct RenderImageAspectRatio +{ + /// + /// Creates an aspect ratio. + /// + /// The positive width component. + /// The positive height component. + public RenderImageAspectRatio(uint width, uint height) + { + if (width is 0 or > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(width), width, $"Value must be between 1 and {int.MaxValue}."); + } + + if (height is 0 or > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(height), height, $"Value must be between 1 and {int.MaxValue}."); + } + + Width = width; + Height = height; + } + + /// + /// Gets the width component. + /// + public uint Width { get; } + + /// + /// Gets the height component. + /// + public uint Height { get; } + + internal void Validate(string parameterName) + { + if (Width is 0 or > int.MaxValue || Height is 0 or > int.MaxValue) + { + throw new ArgumentOutOfRangeException(parameterName, "Aspect-ratio components must be positive 32-bit integers."); + } + } + + internal void AppendToQueryString( + ref UrlStringBuilder chars, + ReadOnlySpan key) + { + Span value = stackalloc char[21]; + Width.TryFormat(value, out var widthChars, default, CultureInfo.InvariantCulture); + value[widthChars] = '_'; + Height.TryFormat(value[(widthChars + 1)..], out var heightChars, default, CultureInfo.InvariantCulture); + chars.EncodeSafeKeyValue(key, value[..(widthChars + heightChars + 1)]); + } +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCrop.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCrop.cs new file mode 100644 index 0000000..3dfc836 --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCrop.cs @@ -0,0 +1,243 @@ +using System.Text.Json.Nodes; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +/// +/// Defines an immutable rectangular or aspect-ratio crop. +/// +/// +/// Rectangular crops may constrain the horizontal axis, the vertical axis, or both. Aspect-ratio +/// crops constrain exactly one axis and calculate the other from the requested ratio. +/// +/// +/// +/// var crop = RenderImageCrop.Rectangle( +/// horizontal: RenderImageCropSpan.Between( +/// RenderImageCropPosition.Percent(33), +/// RenderImageCropPosition.Percent(66))); +/// +/// var square = RenderImageCrop.AspectRatioFromHeight( +/// new RenderImageAspectRatio(1, 1), +/// RenderImageCropSpan.Sized(RenderImageCropSize.Percent(100)), +/// widthOrigin: RenderImageCropOrigin.Center); +/// +/// +public sealed class RenderImageCrop +{ + private RenderImageCrop( + RenderImageAspectRatio? aspectRatio, + RenderImageCropSpan? horizontal, + RenderImageCropSpan? vertical, + RenderImageCropOrigin? otherAxisOrigin) + { + AspectRatio = aspectRatio; + Horizontal = horizontal; + Vertical = vertical; + OtherAxisOrigin = otherAxisOrigin; + } + + /// + /// Gets the requested aspect ratio, or for a rectangular crop. + /// + public RenderImageAspectRatio? AspectRatio { get; } + + /// + /// Gets the horizontal crop span. + /// + public RenderImageCropSpan? Horizontal { get; } + + /// + /// Gets the vertical crop span. + /// + public RenderImageCropSpan? Vertical { get; } + + /// + /// Gets the origin of the axis calculated from . + /// + public RenderImageCropOrigin? OtherAxisOrigin { get; } + + /// + /// Creates a rectangular crop from one or two axis spans. + /// + /// The optional horizontal crop span. + /// The optional vertical crop span. + /// The configured crop. + /// Both spans are . + public static RenderImageCrop Rectangle( + RenderImageCropSpan? horizontal = null, + RenderImageCropSpan? vertical = null) + { + if (horizontal == null && vertical == null) + { + throw new ArgumentException("At least one crop span must be provided."); + } + + return new RenderImageCrop(null, horizontal, vertical, null); + } + + /// + /// Creates an aspect-ratio crop whose width is defined by a horizontal span. + /// + /// The desired output aspect ratio. + /// The horizontal span that determines the crop width. + /// The origin used to position the calculated height. + /// The configured crop. + public static RenderImageCrop AspectRatioFromWidth( + RenderImageAspectRatio aspectRatio, + RenderImageCropSpan width, + RenderImageCropOrigin heightOrigin = RenderImageCropOrigin.Start) + { + aspectRatio.Validate(nameof(aspectRatio)); + ArgumentNullException.ThrowIfNull(width); + heightOrigin.Validate(nameof(heightOrigin)); + return new RenderImageCrop(aspectRatio, width, null, heightOrigin); + } + + /// + /// Creates an aspect-ratio crop whose height is defined by a vertical span. + /// + /// The desired output aspect ratio. + /// The vertical span that determines the crop height. + /// The origin used to position the calculated width. + /// The configured crop. + public static RenderImageCrop AspectRatioFromHeight( + RenderImageAspectRatio aspectRatio, + RenderImageCropSpan height, + RenderImageCropOrigin widthOrigin = RenderImageCropOrigin.Start) + { + aspectRatio.Validate(nameof(aspectRatio)); + ArgumentNullException.ThrowIfNull(height); + widthOrigin.Validate(nameof(widthOrigin)); + return new RenderImageCrop(aspectRatio, null, height, widthOrigin); + } + + internal void AppendToQueryString( + ref UrlStringBuilder chars, + JsonObject? templateValues = null) + { + AspectRatio?.AppendToQueryString( + ref chars, + RenderImageOptions.QueryKey( + templateValues, + "aspect_ratio", + "__ro_aspect_ratio")); + + var xOrigin = GetOwnOrigin(Horizontal); + var yOrigin = GetOwnOrigin(Vertical); + + if (AspectRatio.HasValue && OtherAxisOrigin is not null and not RenderImageCropOrigin.Start) + { + if (Horizontal != null) + { + yOrigin = OtherAxisOrigin; + } + else + { + xOrigin = OtherAxisOrigin; + } + } + + AppendOriginIfNotNull( + ref chars, + xOrigin, + RenderImageOptions.QueryKey(templateValues, "x_origin", "__ro_x_origin")); + AppendOriginIfNotNull( + ref chars, + yOrigin, + RenderImageOptions.QueryKey(templateValues, "y_origin", "__ro_y_origin")); + AppendPositionIfNotNull( + ref chars, + Horizontal?.Start, + RenderImageOptions.QueryKey(templateValues, "x_1", "__ro_x_1")); + AppendPositionIfNotNull( + ref chars, + Horizontal?.End, + RenderImageOptions.QueryKey(templateValues, "x_2", "__ro_x_2")); + AppendPositionIfNotNull( + ref chars, + Vertical?.Start, + RenderImageOptions.QueryKey(templateValues, "y_1", "__ro_y_1")); + AppendPositionIfNotNull( + ref chars, + Vertical?.End, + RenderImageOptions.QueryKey(templateValues, "y_2", "__ro_y_2")); + AppendSizeWithTemplateFallback( + ref chars, + Horizontal?.Size, + templateValues, + "crop_width", + "crop_w", + "__ro_crop_width", + "__ro_crop_w"); + AppendSizeWithTemplateFallback( + ref chars, + Vertical?.Size, + templateValues, + "crop_height", + "crop_h", + "__ro_crop_height", + "__ro_crop_h"); + } + + private static RenderImageCropOrigin? GetOwnOrigin(RenderImageCropSpan? span) + { + return span?.Origin is { } origin && origin != RenderImageCropOrigin.Start ? origin : null; + } + + private static void AppendOriginIfNotNull( + ref UrlStringBuilder chars, + RenderImageCropOrigin? origin, + ReadOnlySpan key) + { + if (origin.HasValue) + { + origin.Value.AppendToQueryString(key, ref chars); + } + } + + private static void AppendPositionIfNotNull( + ref UrlStringBuilder chars, + RenderImageCropPosition? position, + ReadOnlySpan key) + { + if (position.HasValue) + { + position.Value.AppendToQueryString(ref chars, key); + } + } + + private static void AppendSizeWithTemplateFallback( + ref UrlStringBuilder chars, + RenderImageCropSize? size, + JsonObject? templateValues, + string key, + string alias, + string fallbackKey, + string fallbackAlias) + { + if (!size.HasValue) + { + return; + } + + var hasKeyCollision = templateValues?.ContainsKey(key) == true; + var hasAliasCollision = templateValues?.ContainsKey(alias) == true; + + if (!hasKeyCollision && !hasAliasCollision) + { + size.Value.AppendToQueryString(ref chars, key); + return; + } + + if (hasKeyCollision) + { + size.Value.AppendToQueryString(ref chars, fallbackKey); + } + + if (hasAliasCollision) + { + size.Value.AppendToQueryString(ref chars, fallbackAlias); + } + } +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCropOrigin.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCropOrigin.cs new file mode 100644 index 0000000..9bc37fb --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCropOrigin.cs @@ -0,0 +1,22 @@ +namespace HtmlCssToImage.Models; + +/// +/// Specifies where a size-only crop span is positioned within an axis. +/// +public enum RenderImageCropOrigin +{ + /// + /// Positions the span at the beginning of the axis. + /// + Start, + + /// + /// Centers the span within the axis. + /// + Center, + + /// + /// Positions the span at the end of the axis. + /// + End +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCropPosition.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCropPosition.cs new file mode 100644 index 0000000..071b244 --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCropPosition.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +/// +/// Defines a non-negative position along a crop axis. +/// +public readonly record struct RenderImageCropPosition : IParsable +{ + private RenderImageCropPosition(int value, RenderImageCropUnit unit) + { + Value = value; + Unit = unit; + } + + /// + /// Gets the numeric value. + /// + public int Value { get; } + + /// + /// Gets the measurement unit. + /// + public RenderImageCropUnit Unit { get; } + + /// + /// Creates a pixel position. + /// + /// A non-negative pixel value. + /// The position. + public static RenderImageCropPosition Pixels(int value) + { + CropHelpers.ValidatePosition(value, RenderImageCropUnit.Pixels, nameof(value)); + return new RenderImageCropPosition(value, RenderImageCropUnit.Pixels); + } + + /// + /// Creates a percentage position. + /// + /// A percentage greater than zero and no greater than 100. + /// The position. + public static RenderImageCropPosition Percent(int value) + { + CropHelpers.ValidatePosition(value, RenderImageCropUnit.Percent, nameof(value)); + return new RenderImageCropPosition(value, RenderImageCropUnit.Percent); + } + + /// + public static RenderImageCropPosition Parse(string s, IFormatProvider? provider) + { + return TryParse(s, provider, out var result) + ? result + : throw new FormatException($"'{s}' is not a valid crop position."); + } + + /// + public static bool TryParse( + [NotNullWhen(true)] string? s, + IFormatProvider? provider, + out RenderImageCropPosition result) + { + result = default; + if (!CropHelpers.TryParsePosition(s, provider, out var value, out var unit) || + !CropHelpers.IsValidPosition(value, unit)) + { + return false; + } + + result = new RenderImageCropPosition(value, unit); + return true; + } + + internal void Validate(string parameterName) + { + CropHelpers.ValidatePosition(Value, Unit, parameterName); + } + + internal void AppendToQueryString(ref UrlStringBuilder chars, ReadOnlySpan key) + { + CropHelpers.AppendToQueryString(Value, Unit, key, ref chars); + } +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCropSize.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCropSize.cs new file mode 100644 index 0000000..ddce84b --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCropSize.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +/// +/// Defines a positive size along a crop axis. +/// +public readonly record struct RenderImageCropSize : IParsable +{ + private RenderImageCropSize(uint value, RenderImageCropUnit unit) + { + Value = value; + Unit = unit; + } + + /// + /// Gets the numeric value. + /// + public uint Value { get; } + + /// + /// Gets the measurement unit. + /// + public RenderImageCropUnit Unit { get; } + + /// + /// Creates a pixel size. + /// + /// A pixel value greater than zero. + /// The size. + public static RenderImageCropSize Pixels(uint value) + { + CropHelpers.ValidateSize(value, RenderImageCropUnit.Pixels, nameof(value)); + return new RenderImageCropSize(value, RenderImageCropUnit.Pixels); + } + + /// + /// Creates a percentage size. + /// + /// A percentage greater than zero and no greater than 100. + /// The size. + public static RenderImageCropSize Percent(uint value) + { + CropHelpers.ValidateSize(value, RenderImageCropUnit.Percent, nameof(value)); + return new RenderImageCropSize(value, RenderImageCropUnit.Percent); + } + + /// + public static RenderImageCropSize Parse(string s, IFormatProvider? provider) + { + return TryParse(s, provider, out var result) + ? result + : throw new FormatException($"'{s}' is not a valid crop size."); + } + + /// + public static bool TryParse( + [NotNullWhen(true)] string? s, + IFormatProvider? provider, + out RenderImageCropSize result) + { + result = default; + if (!CropHelpers.TryParseSize(s, provider, out var value, out var unit) || + !CropHelpers.IsValidSize(value, unit)) + { + return false; + } + + result = new RenderImageCropSize(value, unit); + return true; + } + + internal void Validate(string parameterName) + { + CropHelpers.ValidateSize(Value, Unit, parameterName); + } + + internal void AppendToQueryString(ref UrlStringBuilder chars, ReadOnlySpan key) + { + CropHelpers.AppendToQueryString(Value, Unit, key, ref chars); + } +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCropSpan.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCropSpan.cs new file mode 100644 index 0000000..fcf4388 --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCropSpan.cs @@ -0,0 +1,102 @@ +namespace HtmlCssToImage.Models; + +/// +/// Defines a valid span along one axis of a crop. +/// +/// +/// A span can extend from a position to the far edge, lie between two positions, use a size after a +/// position, or position a size at the start, center, or end of an axis. +/// +public sealed class RenderImageCropSpan +{ + private RenderImageCropSpan( + RenderImageCropPosition? start, + RenderImageCropPosition? end, + RenderImageCropSize? size, + RenderImageCropOrigin? origin) + { + Start = start; + End = end; + Size = size; + Origin = origin; + } + + /// + /// Gets the starting position, or for a size positioned by . + /// + public RenderImageCropPosition? Start { get; } + + /// + /// Gets the ending position for a bounded span. + /// + public RenderImageCropPosition? End { get; } + + /// + /// Gets the span size when the span is size-based. + /// + public RenderImageCropSize? Size { get; } + + /// + /// Gets the origin used to position a size-only span, or when the span + /// has an explicit starting position. + /// + public RenderImageCropOrigin? Origin { get; } + + /// + /// Creates a span from a position through the far edge of the axis. + /// + /// The starting position. + /// The configured crop span. + public static RenderImageCropSpan From(RenderImageCropPosition start) + { + start.Validate(nameof(start)); + return new RenderImageCropSpan(start, null, null, null); + } + + /// + /// Creates a span between two positions. + /// + /// The first position. + /// The second position. + /// The configured crop span. + public static RenderImageCropSpan Between(RenderImageCropPosition start, RenderImageCropPosition end) + { + start.Validate(nameof(start)); + end.Validate(nameof(end)); + + if (start.Unit == end.Unit && end.Value <= start.Value) + { + throw new ArgumentOutOfRangeException(nameof(end), end.Value, "End must be greater than start when both positions use the same unit."); + } + + return new RenderImageCropSpan(start, end, null, null); + } + + /// + /// Creates a sized span beginning at a position. + /// + /// The starting position. + /// The span size. + /// The configured crop span. + public static RenderImageCropSpan SizedFrom(RenderImageCropPosition start, RenderImageCropSize size) + { + start.Validate(nameof(start)); + size.Validate(nameof(size)); + return new RenderImageCropSpan(start, null, size, null); + } + + /// + /// Creates a sized span positioned relative to an axis origin. + /// + /// The span size. + /// The position of the span within the axis. + /// The configured crop span. + public static RenderImageCropSpan Sized( + RenderImageCropSize size, + RenderImageCropOrigin origin = RenderImageCropOrigin.Start) + { + size.Validate(nameof(size)); + origin.Validate(nameof(origin)); + return new RenderImageCropSpan(null, null, size, origin); + } +} diff --git a/src/HtmlCssToImage/Models/Cropping/RenderImageCropUnit.cs b/src/HtmlCssToImage/Models/Cropping/RenderImageCropUnit.cs new file mode 100644 index 0000000..849b0da --- /dev/null +++ b/src/HtmlCssToImage/Models/Cropping/RenderImageCropUnit.cs @@ -0,0 +1,17 @@ +namespace HtmlCssToImage.Models; + +/// +/// Specifies the unit used by a crop position or size. +/// +public enum RenderImageCropUnit +{ + /// + /// The value is measured in pixels. + /// + Pixels, + + /// + /// The value is a percentage of the corresponding image dimension. + /// + Percent +} diff --git a/src/HtmlCssToImage/Models/RenderImageOptions.cs b/src/HtmlCssToImage/Models/RenderImageOptions.cs new file mode 100644 index 0000000..4673e31 --- /dev/null +++ b/src/HtmlCssToImage/Models/RenderImageOptions.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Nodes; +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Models; + +/// +/// Defines output format, sizing, and cropping options for rendered images. +/// +public sealed class RenderImageOptions +{ + /// + /// Gets or sets the output image format. PNG is used when no format is specified. + /// + public RenderImageFormat? Format { get; set; } + + /// + /// Gets or sets the output DPI metadata value. Valid API values are greater than 30 and less than 600. + /// + public ushort? Dpi { get; set; } + + /// + /// Gets or sets the positive output height in pixels. When width is omitted, the source aspect ratio is preserved. + /// + public ushort? Height { get; set; } + + /// + /// Gets or sets the positive output width in pixels. When height is omitted, the source aspect ratio is preserved. + /// + public ushort? Width { get; set; } + + /// + /// Gets or sets the crop applied before the output dimensions. + /// + public RenderImageCrop? Crop { get; set; } + + internal void AppendToBuilder( + ref UrlStringBuilder builder, + bool includeFormat = true, + JsonObject? templateValues = null) + { + Validate(); + + if (includeFormat && Format.HasValue) + { + builder.AppendLiteral('.'); + builder.AppendLiteral(Format.Value.RenderFormatToExtensionWithoutDot()); + } + + if (Dpi.HasValue) + { + builder.WriteSafeKey( + QueryKey(templateValues, "dpi", "__ro_dpi"), + Dpi.Value); + } + + if (Height.HasValue) + { + builder.WriteSafeKey( + QueryKey(templateValues, "height", "__ro_height"), + Height.Value); + } + + if (Width.HasValue) + { + builder.WriteSafeKey( + QueryKey(templateValues, "width", "__ro_width"), + Width.Value); + } + + Crop?.AppendToQueryString(ref builder, templateValues); + } + + private void Validate() + { + if (Dpi is <= 30 or >= 600) + { + throw new ArgumentOutOfRangeException( + nameof(Dpi), + Dpi, + "DPI must be greater than 30 and less than 600."); + } + + if (Height is 0) + { + throw new ArgumentOutOfRangeException( + nameof(Height), + Height, + "Height must be greater than zero."); + } + + if (Width is 0) + { + throw new ArgumentOutOfRangeException( + nameof(Width), + Width, + "Width must be greater than zero."); + } + } + + internal static string QueryKey( + JsonObject? templateValues, + string key, + string fallbackKey) + { + return templateValues?.ContainsKey(key) == true ? fallbackKey : key; + } + + /// + /// Creates the URL for rendering an existing image with the supplied options. + /// + /// The API base URL. + /// The image identifier. + /// The rendering options. + /// The render URL. + /// or is empty. + /// is . + /// A DPI or output dimension is outside its valid range. + public static string ToUrl(string baseUrl, string imageId, RenderImageOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseUrl); + ArgumentException.ThrowIfNullOrWhiteSpace(imageId); + ArgumentNullException.ThrowIfNull(options); + + Span initialBuffer = stackalloc char[512]; + UrlStringBuilder builder = new(initialBuffer); + try + { + builder.AppendLiteral(baseUrl.AsSpan().TrimEnd('/')); + builder.AppendLiteral(HtmlCssToImageClient.CREATE_OR_GET_PATH); + builder.AppendLiteral('/'); + builder.AppendLiteral(imageId); + options.AppendToBuilder(ref builder); + return builder.FullSpan.ToString(); + } + finally + { + builder.Dispose(); + } + } +} diff --git a/src/HtmlCssToImage/README.md b/src/HtmlCssToImage/README.md index cdd756c..1727de2 100644 --- a/src/HtmlCssToImage/README.md +++ b/src/HtmlCssToImage/README.md @@ -98,8 +98,43 @@ Read more about signed URLs in the [create-and-render docs](https://docs.htmlcss These URLs are tied to the API Key & API Id you provide when creating the client. If you change them or disable the keys, you'll need to generate new URLs. +The signature covers the complete query string, including output sizing and cropping options. If you change any query parameter, generate a new signed URL. + These methods are handy when you have a lot of content that may never be rendered, and want to render on-demand, as to not waste your image credits. +### Output Sizing and Cropping + +Use `RenderImageOptions` with an existing image, a create-and-render request, or a template URL: + +```csharp +var renderOptions = new RenderImageOptions +{ + Format = RenderImageFormat.WEBP, + Dpi = 144, + Width = 1200, + Crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center)) +}; + +var existingImageUrl = client.ImageUrl("image-id", renderOptions); + +var createAndRenderUrl = client.CreateAndRenderUrl( + new CreateUrlImageRequest { Url = "https://example.com" }, + renderOptions); + +var templatedImageUrl = client.CreateTemplatedImageUrl( + "template-id", + new JsonObject { ["title"] = "Hello" }, + templateVersion: null, + renderOptions); +``` + +Crop positions and sizes use integer pixels or whole percentages. The [.NET cropping guide](docs/Cropping.md) walks through every crop shape, unit, and origin with complete examples. See the API's [cropping parameters](https://docs.htmlcsstoimage.com/getting-started/using-the-api/#cropping-parameters) for the corresponding raw query parameters. + +DPI must be greater than 30 and less than 600; width and height must be greater than zero. If your template model has keys that overlap with render options, the client automatically uses a fallback that the API understands. + ## Performance & Native AOT This library is built with performance in mind and is fully compatible with **Native AOT** (Ahead-of-Time) compilation in .NET 9+. @@ -127,4 +162,4 @@ Use [HtmlCssToImage.TagHelpers](https://github.com/htmlcsstoimage/dotnet-client/ > Check out the [HTML/CSS To Image Docs](https://docs.htmlcsstoimage.com) for more details on the API's capabilities. > [!TIP] -> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). \ No newline at end of file +> Get started for free at [htmlcsstoimage.com](https://htmlcsstoimage.com). diff --git a/src/HtmlCssToImage/docs/Cropping.md b/src/HtmlCssToImage/docs/Cropping.md new file mode 100644 index 0000000..7f4e793 --- /dev/null +++ b/src/HtmlCssToImage/docs/Cropping.md @@ -0,0 +1,237 @@ +# Cropping with the .NET client + +Cropping is configured through `RenderImageOptions.Crop`. The crop is applied first; `Width` and +`Height` then resize the cropped result. + +```csharp +using System.Text.Json.Nodes; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; + +var options = new RenderImageOptions +{ + Crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center)) +}; +``` + +## The crop model + +A rectangular crop contains a horizontal span, a vertical span, or both: + +- The horizontal span selects the left-to-right range. +- The vertical span selects the top-to-bottom range. +- Omitting one span leaves that entire axis uncropped. + +Each span can use one of four forms: + +| Form | Meaning | +| --- | --- | +| `From(position)` | Start at a position and continue to the far edge. | +| `Between(start, end)` | Crop between two positions. | +| `SizedFrom(position, size)` | Start at an exact position and crop a fixed size. | +| `Sized(size, origin)` | Position a fixed size at the start, center, or end of the axis. | + +Positions and sizes support integer pixels or whole percentages: + +```csharp +RenderImageCropPosition.Pixels(120); +RenderImageCropPosition.Percent(25); + +RenderImageCropSize.Pixels(800); +RenderImageCropSize.Percent(60); +``` + +Pixel positions may be zero. All sizes must be greater than zero. Percentage positions and sizes +must be from 1 through 100. To begin at the first pixel, use `Pixels(0)` or a size with +`RenderImageCropOrigin.Start`. + +## Crop from a position to the edge + +Use `From` when only the starting coordinate matters. This example removes the first 200 pixels +from the left and keeps everything to the right: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.From( + RenderImageCropPosition.Pixels(200))); +``` + +The same operation can be applied vertically. This example removes the top 10 percent: + +```csharp +var crop = RenderImageCrop.Rectangle( + vertical: RenderImageCropSpan.From( + RenderImageCropPosition.Percent(10))); +``` + +## Crop between two positions + +Use `Between` to provide both boundaries. This keeps the middle third of the image horizontally +and leaves the full height: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Between( + RenderImageCropPosition.Percent(33), + RenderImageCropPosition.Percent(66))); +``` + +Horizontal and vertical spans are independent, so a bounded rectangle can use different units on +each axis: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Between( + RenderImageCropPosition.Pixels(100), + RenderImageCropPosition.Pixels(900)), + vertical: RenderImageCropSpan.Between( + RenderImageCropPosition.Percent(10), + RenderImageCropPosition.Percent(90))); +``` + +Positions within the same span may also mix pixels and percentages. When both positions use the +same unit, the end must be greater than the start. + +## Crop a size from an exact position + +Use `SizedFrom` when the starting position and crop size are known. The units do not need to match. +This example starts 120 pixels from the left and keeps 50 percent of the source width: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.SizedFrom( + RenderImageCropPosition.Pixels(120), + RenderImageCropSize.Percent(50))); +``` + +Both axes can use this form: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.SizedFrom( + RenderImageCropPosition.Pixels(120), + RenderImageCropSize.Percent(50)), + vertical: RenderImageCropSpan.SizedFrom( + RenderImageCropPosition.Pixels(80), + RenderImageCropSize.Pixels(600))); +``` + +## Position a crop by its origin + +Use `Sized` when the size is known but an exact starting coordinate is not needed: + +```csharp +var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center), + vertical: RenderImageCropSpan.Sized( + RenderImageCropSize.Pixels(600), + RenderImageCropOrigin.End)); +``` + +Origins have the following meaning: + +| Origin | Horizontal axis | Vertical axis | +| --- | --- | --- | +| `Start` | Left | Top | +| `Center` | Center | Center | +| `End` | Right | Bottom | + +`Start` is the default, so these are equivalent: + +```csharp +RenderImageCropSpan.Sized(RenderImageCropSize.Pixels(500)); + +RenderImageCropSpan.Sized( + RenderImageCropSize.Pixels(500), + RenderImageCropOrigin.Start); +``` + +## Enforce an aspect ratio + +An aspect-ratio crop defines one axis and calculates the other. Use +`AspectRatioFromWidth` when the horizontal span is known: + +```csharp +var crop = RenderImageCrop.AspectRatioFromWidth( + new RenderImageAspectRatio(16, 9), + RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center), + heightOrigin: RenderImageCropOrigin.Center); +``` + +In this example, the horizontal span determines the crop width. The API calculates the height +needed for a 16:9 result and centers that calculated height vertically. + +Use `AspectRatioFromHeight` when the vertical span is known: + +```csharp +var crop = RenderImageCrop.AspectRatioFromHeight( + new RenderImageAspectRatio(1, 1), + RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center), + widthOrigin: RenderImageCropOrigin.End); +``` + +Here, the vertical span determines the height. The API calculates the width needed for a square +result and aligns that calculated width to the right. + +The origin on the span positions the axis you supplied. `heightOrigin` or `widthOrigin` positions +the other, calculated axis. Both default to `Start`. + +## Use the crop in a render URL + +The same `RenderImageOptions` can be used for existing images, create-and-render URLs, and template +URLs: + +```csharp +var options = new RenderImageOptions +{ + Format = RenderImageFormat.WEBP, + Width = 1200, + Crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center)) +}; + +var existingImageUrl = client.ImageUrl("image-id", options); + +var createAndRenderUrl = client.CreateAndRenderUrl( + new CreateUrlImageRequest { Url = "https://example.com" }, + options); + +var templatedImageUrl = client.CreateTemplatedImageUrl( + "template-id", + new JsonObject { ["title"] = "Hello" }, + templateVersion: null, + options); +``` + +`Width` and `Height` describe the final output dimensions, not the crop boundaries. If only one is +provided, the cropped image's aspect ratio is preserved. + +## Template value collisions + +Template values and render options share the URL query string. If your template model has keys that +overlap with render options, the client automatically uses a fallback that the API understands. +Use the typed crop API normally; no special handling is needed. + +## Validation summary + +- `RenderImageCrop.Rectangle` requires at least one axis. +- Pixel positions must be zero or greater. +- Pixel sizes must be greater than zero. +- Percentage positions and sizes must be from 1 through 100. +- Measurements use whole numbers; fractional pixels and percentages are not supported. +- For a `Between` span whose positions use the same unit, the end must be greater than the start. +- Aspect-ratio width and height components must each be positive 32-bit integers. + +For the corresponding raw query parameters, see the API's +[cropping parameters](https://docs.htmlcsstoimage.com/getting-started/using-the-api/#cropping-parameters). diff --git a/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/QueryStringEncoderBenchmark.cs b/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/QueryStringEncoderBenchmark.cs deleted file mode 100644 index 8be753d..0000000 --- a/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/QueryStringEncoderBenchmark.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text; -using System.Web; -using BenchmarkDotNet.Attributes; -using HtmlCssToImage.Helpers; -using HtmlCssToImage.Models; -using HtmlCssToImage.Models.Requests; - -namespace HtmlCssToImage.Benchmarks.Benchmarks; -[MemoryDiagnoser] -public class QueryStringEncoderBenchmark -{ - private readonly CreateUrlImageRequest _request = new() - { - Url = "https://example.com/blog/post-1?utm_source=twitter&campaign=winter_sale&emoji=💯", - Css = "body { background: #f0f0f0; } .hero { font-family: 'Open Sans'; }", - ViewportWidth = 1200, - ViewportHeight = 630, - DeviceScale = 2.5, - MsDelay = 500, - FullScreen = true, - ColorScheme = ColorSchemeType.dark, - Timezone = "America/New_York" - }; - - - [Benchmark(Baseline = true)] - public string BuiltIn_HttpUtility() - { - var sb = new StringBuilder(); - sb.Append("url=").Append(HttpUtility.UrlEncode(_request.Url)); - sb.Append("&css=").Append(HttpUtility.UrlEncode(_request.Css)); - sb.Append("&viewport_width=").Append(_request.ViewportWidth); - sb.Append("&viewport_height=").Append(_request.ViewportHeight); - sb.Append("&device_scale=").Append(_request.DeviceScale); - sb.Append("&ms_delay=").Append(_request.MsDelay); - sb.Append("&full_screen=true"); - sb.Append("&color_scheme=").Append(_request.ColorScheme?.ToString()); - sb.Append("&timezone=").Append(HttpUtility.UrlEncode(_request.Timezone)); - return sb.ToString(); - } - - - [Benchmark] - public string HCTI_QueryStringEncoder() - { - ArrayOrSpan chars = new(stackalloc char[512]); - try - { - HtmlCssToImageClient.CreateAndRenderUrlQueryString(_request, ref chars); - - var result = new string(chars.LimitedSpan); - - - return result; - } - finally - { - chars.Dispose(); - } - - } -} \ No newline at end of file diff --git a/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/UrlStringBuilderBenchmark.cs b/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/UrlStringBuilderBenchmark.cs new file mode 100644 index 0000000..bf4248f --- /dev/null +++ b/src/tests/HtmlCssToImage.Benchmarks/Benchmarks/UrlStringBuilderBenchmark.cs @@ -0,0 +1,93 @@ +using System.Text; +using System.Web; +using BenchmarkDotNet.Attributes; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; + +namespace HtmlCssToImage.Benchmarks.Benchmarks; + +[MemoryDiagnoser] +public class UrlStringBuilderBenchmark +{ + private readonly CreateUrlImageRequest _request = new() + { + Url = "https://example.com/blog/post-1?utm_source=twitter&campaign=winter_sale&emoji=💯", + Css = "body { background: #f0f0f0; } .hero { font-family: 'Open Sans'; }", + ViewportWidth = 1200, + ViewportHeight = 630, + DeviceScale = 2.5, + MsDelay = 500, + FullScreen = true, + ColorScheme = ColorSchemeType.dark, + Timezone = "America/New_York" + }; + + [Benchmark(Baseline = true)] + public string BuiltInHttpUtility() + { + var sb = new StringBuilder(); + sb.Append("url=").Append(HttpUtility.UrlEncode(_request.Url)); + sb.Append("&css=").Append(HttpUtility.UrlEncode(_request.Css)); + sb.Append("&viewport_width=").Append(_request.ViewportWidth); + sb.Append("&viewport_height=").Append(_request.ViewportHeight); + sb.Append("&device_scale=").Append(_request.DeviceScale); + sb.Append("&ms_delay=").Append(_request.MsDelay); + sb.Append("&full_screen=true"); + sb.Append("&color_scheme=").Append(_request.ColorScheme?.ToString()); + sb.Append("&timezone=").Append(HttpUtility.UrlEncode(_request.Timezone)); + return sb.ToString(); + } + + [Benchmark] + public string BuiltInUri() + { + var sb = new StringBuilder(); + sb.Append("url=").Append(Uri.EscapeDataString(_request.Url)); + sb.Append("&css=").Append(Uri.EscapeDataString(_request.Css!)); + sb.Append("&viewport_width=").Append(_request.ViewportWidth); + sb.Append("&viewport_height=").Append(_request.ViewportHeight); + sb.Append("&device_scale=").Append(_request.DeviceScale); + sb.Append("&ms_delay=").Append(_request.MsDelay); + sb.Append("&full_screen=true"); + sb.Append("&color_scheme=").Append(_request.ColorScheme?.ToString()); + sb.Append("&timezone=").Append(Uri.EscapeDataString(_request.Timezone!)); + return sb.ToString(); + } + + [Benchmark] + public string HctiQueryStringBuilder() + { + UrlStringBuilder builder = new(stackalloc char[512]); + try + { + HtmlCssToImageClient.CreateAndRenderUrlQueryString(_request, ref builder); + + var result = new string(builder.QueryString(false)); + return result; + } + finally + { + builder.Dispose(); + } + } + [Benchmark] + public string HctiQueryStringBuilderDirect() + { + using UrlStringBuilder builder = new(stackalloc char[512]); + + builder.EncodeSafeKey("url",_request.Url); + builder.EncodeSafeKey("css",_request.Css); + builder.WriteSafeKey("viewport_width",_request.ViewportWidth!.Value); + builder.WriteSafeKey("viewport_height",_request.ViewportHeight!.Value); + builder.WriteSafeKey("device_scale",_request.DeviceScale!.Value); + builder.WriteSafeKey("ms_delay",_request.MsDelay!.Value); + builder.EncodeSafeKeyValue("full_screen","true"); + builder.EncodeSafeKeyValue("color_scheme",_request.ColorScheme!.Value.ColorSchemeString()); + builder.EncodeSafeKey("timezone",_request.Timezone); + + var result = builder.QueryString(false); + return result.ToString(); + + } +} diff --git a/src/tests/HtmlCssToImage.Tests/HtmlCssToImageClientTests.cs b/src/tests/HtmlCssToImage.Tests/HtmlCssToImageClientTests.cs index f8ab1db..a4a54cc 100644 --- a/src/tests/HtmlCssToImage.Tests/HtmlCssToImageClientTests.cs +++ b/src/tests/HtmlCssToImage.Tests/HtmlCssToImageClientTests.cs @@ -2,7 +2,9 @@ using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using System.Web; using HtmlCssToImage.Models; using HtmlCssToImage.Models.Requests; @@ -90,6 +92,30 @@ public void CreateUrl_GeneratesValidSignedUrl(QueryStringType type, RenderImageF Assert.Equal(HexLowerHmac(_options.ApiKey, uri.Query[1..]), token_part); } + [Fact] + public void CreateAndRenderUrl_WithRenderOptions_AppendsFormatAndOutputOptions() + { + var client = CreateClient(); + var request = new CreateUrlImageRequest { Url = "https://google.com" }; + var options = new RenderImageOptions + { + Format = RenderImageFormat.PNG, + Dpi = 144, + Height = 400, + Width = 600 + }; + + var result = client.CreateAndRenderUrl(request, options); + var uri = new Uri(result); + + Assert.EndsWith("/png", uri.AbsolutePath); + Assert.Equal( + "?url=https%3A%2F%2Fgoogle.com&dpi=144&height=400&width=600", + uri.Query); + Assert.Equal( + HexLowerHmac(_options.ApiKey, uri.Query[1..]), + uri.AbsolutePath.Split('/')[^2]); + } [Fact] public void CreateTemplatedImageUrl_WithVersion_IncludesTemplateVersionInQuery() @@ -98,9 +124,190 @@ public void CreateTemplatedImageUrl_WithVersion_IncludesTemplateVersionInQuery() var values = new JsonObject { ["title"] = "Hello" }; var url = client.CreateTemplatedImageUrl("tpl_123", values, templateVersion: 5); + var uri = new Uri(url); + + Assert.Equal("?template_version=5&title=%22Hello%22", uri.Query); + Assert.Equal( + HexLowerHmac(_options.ApiKey, uri.Query[1..]), + uri.AbsolutePath.Split('/')[^1]); + } + + [Fact] + public void CreateTemplatedImageUrl_WithoutValues_PreservesEmptyQuery() + { + var client = CreateClient(); + + var url = client.CreateTemplatedImageUrl("tpl_123", new JsonObject()); + var token = HexLowerHmac(_options.ApiKey, string.Empty); + + Assert.Equal($"https://hcti.io/v1/image/tpl_123/{token}?", url); + } + + [Fact] + public void CreateTemplatedImageUrl_WithRenderOptions_UsesFallbackKeysForTemplateCollisions() + { + var client = CreateClient(); + var values = new JsonObject + { + ["crop_width"] = "template crop width", + ["dpi"] = "template dpi", + ["height"] = "template height", + ["width"] = "template width", + ["x_1"] = "template x" + }; + var options = new RenderImageOptions + { + Format = RenderImageFormat.WEBP, + Dpi = 144, + Height = 400, + Width = 600, + Crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.SizedFrom( + RenderImageCropPosition.Pixels(10), + RenderImageCropSize.Pixels(200))) + }; + + var result = client.CreateTemplatedImageUrl( + "tpl_123", + values, + templateVersion: null, + options: options); + var uri = new Uri(result); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.EndsWith("/webp", uri.AbsolutePath); + Assert.Equal("template crop width", ParseTemplateValue(query["crop_width"])); + Assert.Equal("template dpi", ParseTemplateValue(query["dpi"])); + Assert.Equal("template height", ParseTemplateValue(query["height"])); + Assert.Equal("template width", ParseTemplateValue(query["width"])); + Assert.Equal("template x", ParseTemplateValue(query["x_1"])); + Assert.Equal("144", query["__ro_dpi"]); + Assert.Equal("400", query["__ro_height"]); + Assert.Equal("600", query["__ro_width"]); + Assert.Equal("10px", query["__ro_x_1"]); + Assert.Equal("200px", query["__ro_crop_width"]); + Assert.Equal( + HexLowerHmac(_options.ApiKey, uri.Query[1..]), + uri.AbsolutePath.Split('/')[^2]); + } + + [Fact] + public void CreateTemplatedImageUrl_GenericRenderOptionsOverloadsSerializeValuesAndUseFallbacks() + { + IHtmlCssToImageClient client = CreateClient(); + var values = new Dictionary + { + ["height"] = "template height", + ["title"] = "Hello" + }; + var options = new RenderImageOptions + { + Format = RenderImageFormat.JPG, + Height = 400 + }; + var serializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + var typeInfo = + (JsonTypeInfo>)serializerOptions.GetTypeInfo( + typeof(Dictionary)); + + var urls = new[] + { + client.CreateTemplatedImageUrl( + "tpl_123", + values, + templateVersion: null, + options), + client.CreateTemplatedImageUrl( + "tpl_123", + values, + typeInfo, + templateVersion: null, + options), + client.CreateTemplatedImageUrl( + "tpl_123", + values, + serializerOptions, + templateVersion: null, + options) + }; - Assert.Contains("template_version=5", url); - Assert.Contains("title=%22Hello%22", url); + foreach (var url in urls) + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.EndsWith("/jpg", uri.AbsolutePath); + Assert.Equal("template height", ParseTemplateValue(query["height"])); + Assert.Equal("Hello", ParseTemplateValue(query["title"])); + Assert.Equal("400", query["__ro_height"]); + Assert.Equal( + HexLowerHmac(_options.ApiKey, uri.Query[1..]), + uri.AbsolutePath.Split('/')[^2]); + } + } + + [Theory] + [InlineData("crop_w", "__ro_crop_w", true)] + [InlineData("crop_h", "__ro_crop_h", false)] + public void CreateTemplatedImageUrl_WithCropAliasCollision_PreservesTemplateValue( + string templateKey, + string fallbackKey, + bool horizontal) + { + var client = CreateClient(); + var values = new JsonObject { [templateKey] = "template crop" }; + var cropSpan = RenderImageCropSpan.Sized(RenderImageCropSize.Percent(25)); + var options = new RenderImageOptions + { + Crop = horizontal + ? RenderImageCrop.Rectangle(horizontal: cropSpan) + : RenderImageCrop.Rectangle(vertical: cropSpan) + }; + + var result = client.CreateTemplatedImageUrl( + "tpl_123", + values, + templateVersion: null, + options); + var query = HttpUtility.ParseQueryString(new Uri(result).Query); + + Assert.Equal("template crop", ParseTemplateValue(query[templateKey])); + Assert.Equal("25%", query[fallbackKey]); + } + + [Fact] + public void CreateTemplatedImageUrl_WithBothCropWidthAliases_PreservesBothTemplateValues() + { + var client = CreateClient(); + var values = new JsonObject + { + ["crop_width"] = "canonical template value", + ["crop_w"] = "alias template value" + }; + var options = new RenderImageOptions + { + Crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized(RenderImageCropSize.Pixels(200))) + }; + + var result = client.CreateTemplatedImageUrl( + "tpl_123", + values, + templateVersion: null, + options); + var query = HttpUtility.ParseQueryString(new Uri(result).Query); + + Assert.Equal( + "canonical template value", + ParseTemplateValue(query["crop_width"])); + Assert.Equal( + "alias template value", + ParseTemplateValue(query["crop_w"])); + Assert.Equal("200px", query["__ro_crop_width"]); + Assert.Equal("200px", query["__ro_crop_w"]); } [Fact] @@ -218,6 +425,17 @@ public async Task CreateImageBatchAsync_WhenError_PopulatesErrorDetails() private static string HexLowerHmac(string key, string value) { - return Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(value))); + return Convert.ToHexStringLower( + HMACSHA256.HashData( + Encoding.UTF8.GetBytes(key), + Encoding.UTF8.GetBytes(value))); + } + + private static T ParseTemplateValue(string? json) + { + Assert.NotNull(json); + var value = JsonNode.Parse(json); + Assert.NotNull(value); + return value.GetValue(); } } diff --git a/src/tests/HtmlCssToImage.Tests/Models/RenderImageOptionsTests.cs b/src/tests/HtmlCssToImage.Tests/Models/RenderImageOptionsTests.cs new file mode 100644 index 0000000..2b18d91 --- /dev/null +++ b/src/tests/HtmlCssToImage.Tests/Models/RenderImageOptionsTests.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using HtmlCssToImage.Models; + +namespace HtmlCssToImage.Tests.Models; + +public class RenderImageOptionsTests +{ + private const string BaseUrl = "https://hcti.io"; + private const string ImageId = "image-id"; + + [Fact] + public void ToUrl_RectangleBetweenPositions_AppendsCoordinateKeys() + { + var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Between( + RenderImageCropPosition.Percent(33), + RenderImageCropPosition.Percent(66))); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?x_1=33%25&x_2=66%25", + result); + } + + [Fact] + public void ToUrl_RectangleComposesIndependentAxisSpans() + { + var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.SizedFrom( + RenderImageCropPosition.Pixels(12), + RenderImageCropSize.Percent(25)), + vertical: RenderImageCropSpan.From( + RenderImageCropPosition.Pixels(8))); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?x_1=12px&y_1=8px&crop_width=25%25", + result); + } + + [Fact] + public void ToUrl_RectangleSizeOrigins_AppendsBothOrigins() + { + var crop = RenderImageCrop.Rectangle( + horizontal: RenderImageCropSpan.Sized( + RenderImageCropSize.Pixels(100), + RenderImageCropOrigin.Center), + vertical: RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(50), + RenderImageCropOrigin.End)); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?x_origin=center&y_origin=end&crop_width=100px&crop_height=50%25", + result); + } + + [Fact] + public void ToUrl_AspectRatioFromWidth_AppendsDerivedHeightOrigin() + { + var crop = RenderImageCrop.AspectRatioFromWidth( + new RenderImageAspectRatio(16, 9), + RenderImageCropSpan.Between( + RenderImageCropPosition.Percent(10), + RenderImageCropPosition.Percent(90)), + heightOrigin: RenderImageCropOrigin.Center); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?aspect_ratio=16_9&y_origin=center&x_1=10%25&x_2=90%25", + result); + } + + [Fact] + public void ToUrl_AspectRatioFromHeight_AppendsConstrainedAndDerivedOrigins() + { + var crop = RenderImageCrop.AspectRatioFromHeight( + new RenderImageAspectRatio(1, 1), + RenderImageCropSpan.Sized( + RenderImageCropSize.Percent(80), + RenderImageCropOrigin.Center), + widthOrigin: RenderImageCropOrigin.End); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?aspect_ratio=1_1&x_origin=end&y_origin=center&crop_height=80%25", + result); + } + + [Fact] + public void ToUrl_DefaultOrigins_AreOmitted() + { + var crop = RenderImageCrop.AspectRatioFromHeight( + new RenderImageAspectRatio(1, 1), + RenderImageCropSpan.Sized(RenderImageCropSize.Percent(100))); + + var result = ToUrl(crop); + + Assert.Equal( + "https://hcti.io/v1/image/image-id?aspect_ratio=1_1&crop_height=100%25", + result); + } + + [Fact] + public void ToUrl_WithTrailingSlashOnBaseUrl_DoesNotDuplicateSeparator() + { + var result = RenderImageOptions.ToUrl( + $"{BaseUrl}/", + ImageId, + new RenderImageOptions()); + + Assert.Equal("https://hcti.io/v1/image/image-id", result); + } + + [Theory] + [InlineData(30)] + [InlineData(600)] + public void ToUrl_WithInvalidDpi_Throws(int dpi) + { + var options = new RenderImageOptions { Dpi = (ushort)dpi }; + + Assert.Throws(() => + RenderImageOptions.ToUrl(BaseUrl, ImageId, options)); + } + + [Theory] + [InlineData(31)] + [InlineData(599)] + public void ToUrl_WithBoundaryDpi_AppendsDpi(int dpi) + { + var result = RenderImageOptions.ToUrl( + BaseUrl, + ImageId, + new RenderImageOptions { Dpi = (ushort)dpi }); + + Assert.Equal($"https://hcti.io/v1/image/image-id?dpi={dpi}", result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ToUrl_WithZeroDimension_Throws(bool height) + { + var options = height + ? new RenderImageOptions { Height = 0 } + : new RenderImageOptions { Width = 0 }; + + Assert.Throws(() => + RenderImageOptions.ToUrl(BaseUrl, ImageId, options)); + } + + [Fact] + public void Rectangle_WithoutAnySpan_Throws() + { + Assert.Throws(() => RenderImageCrop.Rectangle()); + } + + [Fact] + public void AspectRatio_DefaultValue_ThrowsWhenUsed() + { + Assert.Throws(() => + RenderImageCrop.AspectRatioFromWidth( + default, + RenderImageCropSpan.From(RenderImageCropPosition.Pixels(10)))); + } + + [Theory] + [InlineData(0u, 1u)] + [InlineData(1u, 0u)] + [InlineData(uint.MaxValue, 1u)] + public void AspectRatio_InvalidComponents_Throw(uint width, uint height) + { + Assert.Throws(() => new RenderImageAspectRatio(width, height)); + } + + [Fact] + public void Between_NonIncreasingPositionsWithSameUnit_Throws() + { + Assert.Throws(() => + RenderImageCropSpan.Between( + RenderImageCropPosition.Pixels(20), + RenderImageCropPosition.Pixels(10))); + } + + [Fact] + public void PositionAndSize_TryParseServerFormats() + { + Assert.True(RenderImageCropPosition.TryParse( + "12", + CultureInfo.InvariantCulture, + out var position)); + Assert.Equal(12, position.Value); + Assert.Equal(RenderImageCropUnit.Pixels, position.Unit); + + Assert.True(RenderImageCropSize.TryParse( + "33%", + CultureInfo.InvariantCulture, + out var size)); + Assert.Equal(33u, size.Value); + Assert.Equal(RenderImageCropUnit.Percent, size.Unit); + + Assert.True(RenderImageCropSize.TryParse( + "100PX", + CultureInfo.InvariantCulture, + out var pixelSize)); + Assert.Equal(100u, pixelSize.Value); + Assert.Equal(RenderImageCropUnit.Pixels, pixelSize.Unit); + } + + [Theory] + [InlineData("-1px")] + [InlineData("0%")] + [InlineData("101%")] + [InlineData("12.5px")] + [InlineData("NaNpx")] + public void Position_TryParseRejectsInvalidValues(string input) + { + Assert.False(RenderImageCropPosition.TryParse( + input, + CultureInfo.InvariantCulture, + out _)); + } + + [Theory] + [InlineData("0px")] + [InlineData("-1px")] + [InlineData("0%")] + [InlineData("101%")] + [InlineData("33.25%")] + [InlineData("Infinitypx")] + public void Size_TryParseRejectsInvalidValues(string input) + { + Assert.False(RenderImageCropSize.TryParse( + input, + CultureInfo.InvariantCulture, + out _)); + } + + private static string ToUrl(RenderImageCrop crop) + { + return RenderImageOptions.ToUrl( + BaseUrl, + ImageId, + new RenderImageOptions { Crop = crop }); + } +} diff --git a/src/tests/HtmlCssToImage.Tests/UrlStringBuilderTests.cs b/src/tests/HtmlCssToImage.Tests/UrlStringBuilderTests.cs new file mode 100644 index 0000000..b665137 --- /dev/null +++ b/src/tests/HtmlCssToImage.Tests/UrlStringBuilderTests.cs @@ -0,0 +1,82 @@ +using HtmlCssToImage.Helpers; + +namespace HtmlCssToImage.Tests; + +public class UrlStringBuilderTests +{ + [Fact] + public void QueryString_KeepsLiteralPrefixOutOfQuery() + { + UrlStringBuilder builder = new(stackalloc char[8]); + try + { + builder.AppendLiteral("https://example.test/path"); + builder.EncodeSafeKeyValue("width", "100"); + builder.Encode("unsafe key", "a&b"); + + Assert.Equal( + "https://example.test/path?width=100&unsafe%20key=a%26b", + builder.FullSpan.ToString()); + Assert.Equal( + "?width=100&unsafe%20key=a%26b", + builder.QueryString(true).ToString()); + Assert.Equal( + "width=100&unsafe%20key=a%26b", + builder.QueryString(false).ToString()); + } + finally + { + builder.Dispose(); + } + } + + [Fact] + public void QueryString_WhenNoParameters_IsEmpty() + { + UrlStringBuilder builder = new(stackalloc char[8]); + try + { + builder.AppendLiteral("https://example.test/path"); + + Assert.Equal("https://example.test/path", builder.FullSpan.ToString()); + Assert.True(builder.QueryString(true).IsEmpty); + Assert.True(builder.QueryString(false).IsEmpty); + } + finally + { + builder.Dispose(); + } + } + + [Fact] + public void EncodeSafeKey_GrowsForEscapedValue() + { + UrlStringBuilder builder = new(stackalloc char[8]); + try + { + builder.EncodeSafeKey("x", "\u0800"); + + Assert.Equal("?x=%E0%A0%80", builder.FullSpan.ToString()); + } + finally + { + builder.Dispose(); + } + } + + [Fact] + public void Encode_GrowsForEscapedKeyAndValue() + { + UrlStringBuilder builder = new(stackalloc char[8]); + try + { + builder.Encode("\u0800", "\u0800"); + + Assert.Equal("?%E0%A0%80=%E0%A0%80", builder.FullSpan.ToString()); + } + finally + { + builder.Dispose(); + } + } +}