Skip to content

Commit

Permalink
feat: [Android] PdfDocument
Browse files Browse the repository at this point in the history
Inport initial implementation from PR unoplatform#1796
  • Loading branch information
artemious7 authored and workgroupengineering committed Mar 7, 2023
1 parent 639af0d commit b86d796
Show file tree
Hide file tree
Showing 13 changed files with 474 additions and 122 deletions.
7 changes: 7 additions & 0 deletions build/PackageDiffIgnore.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9389,6 +9389,13 @@
<Member fullName="System.Void Windows.UI.Xaml.Controls.TextBox.OnIsSpellCheckEnabledChanged(Windows.UI.Xaml.DependencyPropertyChangedEventArgs e)" reason="Changed parameter type and made private." />
<Member fullName="System.Void Windows.UI.Xaml.Controls.TextBox.OnIsTextPredictionEnabledChanged(Windows.UI.Xaml.DependencyPropertyChangedEventArgs e)" reason="Changed parameter type and made private." />
<Member fullName="System.Void Windows.UI.Xaml.Controls.TextBox.OnTextAlignmentChanged(Windows.UI.Xaml.DependencyPropertyChangedEventArgs e)" reason="Changed parameter type and made private." />

<!-- BEGIN Android PDFDocument -->
<Member fullName="System.Void Windows.Data.Pdf.PdfDocument..ctor()"
reason="Parameter-less ctor does not exist in UWP"/>
<Member fullName="System.Void Windows.Data.Pdf.PdfPage..ctor()"
reason="Parameter-less ctor does not exist in UWP"/>
<!-- END Android PDFDocument -->
</Methods>
</IgnoreSet>

Expand Down
3 changes: 2 additions & 1 deletion src/Uno.Foundation/Rect.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public partial struct Rect

public static implicit operator Rect(Android.Graphics.RectF rect) => new Rect(rect.Left, rect.Top, rect.Width(), rect.Height());

public static implicit operator Android.Graphics.RectF(Rect rect) => new Android.Graphics.RectF((int)rect.X, (int)rect.Y, (int)(rect.X + rect.Width), (int)(rect.Y + rect.Height));
public static implicit operator Android.Graphics.RectF(Rect rect) => new Android.Graphics.RectF((float)rect.Left, (float)rect.Top, (float)rect.Right, (float)rect.Bottom);

}
70 changes: 60 additions & 10 deletions src/Uno.UI.RuntimeTests/Helpers/ImageAssert.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,21 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using FluentAssertions.Execution;
using NUnit.Framework;
using Uno.UITest;
using Windows.UI;
using static System.Math;

using Rectangle = System.Drawing.Rectangle;
using Size = System.Drawing.Size;
using Point = System.Drawing.Point;
using SamplesApp.UITests;
using Windows.UI.Xaml.Markup;
using Windows.UI.Xaml;

namespace Uno.UI.RuntimeTests.Helpers;

/// <summary>
/// Screenshot based assertions, to validate individual colors of an image
/// Screen shot based assertions, to validate individual colors of an image
/// </summary>
public static partial class ImageAssert
{
Expand Down Expand Up @@ -161,4 +152,63 @@ public static void HasPixels(RawBitmap actual, params ExpectedPixels[] expectati
}
}
#endregion

/// <summary>
/// Asserts that two image are equal within the given <see href="https://en.wikipedia.org/wiki/Root-mean-square_deviation">RMSE</see>
/// The method it based roughly on ImageMagick implementation to ensure consistency.
/// If the error is greater than or equal to 0.022, the differences are visible to human eyes.
/// <paramref name="expected">Reference image.</paramref>
/// <paramref name="actual">The image to compare with reference</paramref>
/// <paramref name="imperceptibilityThreshold">It is the threshold beyond which the compared images are not considered equal. Default value is 0.022.</paramref>>
/// </summary>
public static async Task AreEqual(RawBitmap expected, RawBitmap actual, double imperceptibilityThreshold = 0.022)
{
await actual.Populate();
await expected.Populate();

using var assertionScope = new AssertionScope("ImageAssert");

if (actual.Width != expected.Width || actual.Height != expected.Height)
{
assertionScope.FailWith($"Images have different resolutions. {Environment.NewLine}expected:({expected.Width},{expected.Height}){Environment.NewLine}actual :({actual.Width},{actual.Height})");
}

var quantity = actual.Width * actual.Height;
double squaresError = 0;

const double scale = 1 / 255d;

for (var x = 0; x < actual.Width; x++)
{
double localError = 0;

for (var y = 0; y < actual.Height; y++)
{
var expectedAlpha = expected[x, y].A * scale;
var actualAlpha = actual[x, y].A * scale;

var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R);
var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G);
var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B);
var a = expectedAlpha - actualAlpha;

var error = r * r + g * g + b * b + a * a;

localError += error;
}

squaresError += localError;
}

var meanSquaresError = squaresError / quantity;

const int channelCount = 4;

meanSquaresError = meanSquaresError / channelCount;
var sqrtMeanSquaresError = Sqrt(meanSquaresError);
if (sqrtMeanSquaresError >= imperceptibilityThreshold)
{
assertionScope.FailWith($"the actual image is not the same as the expected one.{Environment.NewLine}actual RSMD: {sqrtMeanSquaresError}{Environment.NewLine}threshold: {imperceptibilityThreshold}");
}
}
}
3 changes: 3 additions & 0 deletions src/Uno.UI.RuntimeTests/Helpers/RawBitmap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public Color GetPixel(int x, int y)
return Color.FromArgb(a, r, g, b);
}

public Color this[int x, int y] =>
GetPixel(x, y);

/// <summary>
/// Enables the <see cref="GetPixel(int, int)"/> method.
/// </summary>
Expand Down
112 changes: 112 additions & 0 deletions src/Uno.UWP/Data/Pdf/PdfDocument.Android.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Android.Graphics.Pdf;
using Android.OS;
using Windows.Foundation;

namespace Windows.Data.Pdf;

public partial class PdfDocument : IDisposable
{
private readonly PdfRenderer _pdfRenderer;

private PdfDocument(PdfRenderer pdfRenderer)
{
this._pdfRenderer = pdfRenderer ?? throw new ArgumentNullException(nameof(pdfRenderer));
}

public bool IsPasswordProtected => false;

public uint PageCount => (uint)_pdfRenderer.PageCount;

public PdfPage GetPage(uint pageIndex)
{
if (pageIndex >= _pdfRenderer.PageCount)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex), $"In this document the page index cannot be {pageIndex}. Page count is {_pdfRenderer.PageCount}");
}

var pdfPage = _pdfRenderer.OpenPage((int)pageIndex);
return new PdfPage(pdfPage);
}

public static IAsyncOperation<PdfDocument> LoadFromFileAsync(Storage.IStorageFile file)
{
return LoadFromFileAsync(file, null);
}

public static IAsyncOperation<PdfDocument> LoadFromFileAsync(Storage.IStorageFile file, string password)
{
if (!string.IsNullOrEmpty(password))
{
// password protected PDF files are not supported by PdfRenderer in Android
throw new NotImplementedException("The member IAsyncOperation<PdfDocument> PdfDocument.LoadFromFileAsync(IStorageFile file, string password) is not implemented in Uno.");
}

if (file is null)
{
throw new ArgumentNullException(nameof(file));
}

var localpath = file.Path;
var fileDescriptor = localpath.StartsWith('/')
? ParcelFileDescriptor.Open(new Java.IO.File(localpath), ParcelFileMode.ReadOnly)
: Android.App.Application.Context.ContentResolver.OpenFileDescriptor(Android.Net.Uri.Parse(localpath), "r");
var pdfRenderer = new PdfRenderer(fileDescriptor);
var pdfDocument = new PdfDocument(pdfRenderer);

return Task.FromResult(pdfDocument).AsAsyncOperation();
}

public static IAsyncOperation<PdfDocument> LoadFromStreamAsync(Storage.Streams.IRandomAccessStream inputStream)
{
return LoadFromStreamAsync(inputStream, default);
}

public static IAsyncOperation<PdfDocument> LoadFromStreamAsync(Storage.Streams.IRandomAccessStream inputStream, string password)
{
if (!string.IsNullOrEmpty(password))
{
// password protected PDF files are not supported by PdfRenderer in Android
throw new NotImplementedException("The member IAsyncOperation<PdfDocument> PdfDocument.LoadFromStreamAsync(IRandomAccessStream inputStream, string password) is not implemented in Uno.");
}
return AsyncOperation.FromTask(async ct =>
{
var parcel = await GetParcelFileDescriptorFromStreamAsync(inputStream.AsStream(), ct);
var pdfRenderer = new PdfRenderer(parcel);
var pdfDocument = new PdfDocument(pdfRenderer);
return pdfDocument;
});
}

public void Dispose()
{
_pdfRenderer.Dispose();
}

private static Task<ParcelFileDescriptor> GetParcelFileDescriptorFromStreamAsync(Stream input, CancellationToken token) =>
Task.Run(() =>
{
/* PdfRenderer https://developer.android.com/reference/android/graphics/pdf/PdfRenderer#PdfRenderer(android.os.ParcelFileDescriptor)
* PdfRenderer needs a seek-able ParcelFileDescriptor,
* to get it from a stream (without using hacks),
* you need to copy the stream to a temporary file.
*/
var fileName = Path.GetTempFileName();
var file = new Java.IO.File(fileName);
file.DeleteOnExit();
var outputStream = new Java.IO.FileOutputStream(file);
byte[] buf = new byte[4096];
var len = 0;
while (!token.IsCancellationRequested && (len = input.Read(buf, 0, buf.Length)) > 0)
{
outputStream.Write(buf, 0, len);
}
outputStream.Flush();
outputStream.Close();
return ParcelFileDescriptor.Open(file, ParcelFileMode.ReadOnly);
}, token);
}
146 changes: 146 additions & 0 deletions src/Uno.UWP/Data/Pdf/PdfPage.Android.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Android.Graphics;
using Android.Graphics.Pdf;
using Uno;
using Windows.Foundation;
using Windows.Storage.Streams;
using Windows.UI;
using Color = Windows.UI.Color;
using Rect = Windows.Foundation.Rect;

namespace Windows.Data.Pdf;

public partial class PdfPage : IDisposable
{
private readonly PdfRenderer.Page _pdfPage;
// It is an empirical value found by rendering through RenderTargetBitmap
// in png and comparing the dimensions of the images thus obtained.
private const float pointToPixelFactor = 96.00000000f / 72.00000000f;

internal PdfPage(PdfRenderer.Page pdfPage)
{
_pdfPage = pdfPage ?? throw new ArgumentNullException(nameof(pdfPage));
}

[NotImplemented("__ANDROID__")]
public PdfPageDimensions Dimensions { get; } = new PdfPageDimensions();

public uint Index => (uint)_pdfPage.Index;

public Size Size => new Size(Math.Ceiling(_pdfPage.Width * pointToPixelFactor) + double.Epsilon, Math.Ceiling(_pdfPage.Height * pointToPixelFactor) + double.Epsilon);

public IAsyncAction RenderToStreamAsync(IRandomAccessStream outputStream)
{
if (outputStream is null)
{
throw new ArgumentNullException(nameof(outputStream));
}
var size = Size;
var options = new PdfPageRenderOptions
{
DestinationWidth = (uint)size.Width,
DestinationHeight = (uint)size.Height
};
return RenderToStreamAsync(outputStream, options);
}

public IAsyncAction RenderToStreamAsync(IRandomAccessStream outputStream, PdfPageRenderOptions options)
{
#region Validate arguments
if (outputStream is null)
{
throw new ArgumentNullException(nameof(outputStream));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (options.DestinationWidth > int.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(options), $"PdfPageRenderOptions.DestinationWidth = {options.DestinationWidth}. Must be less than or equal to int.MaxValue");
}
if (options.DestinationHeight > int.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(options), $"PdfPageRenderOptions.DestinationHeight = {options.DestinationHeight}. Must be less than or equal to int.MaxValue");
}
#endregion

return Task.Run(() =>
{
const int quality = 100;
var bitmap = RenderInternal(options);
bitmap.Compress(Bitmap.CompressFormat.Png, quality, outputStream.AsStream());
return bitmap;
}).AsAsyncAction();
}

private Bitmap RenderInternal(PdfPageRenderOptions options)
{
var destination = new Size(options.DestinationWidth, options.DestinationHeight);

var sourceRect = options.SourceRect;
#region handle 0-width and 0-height
if (destination.Width == 0 && destination.Height == 0)
{
// destination size not set - render with the page original size
destination = Size;
}
else if (destination.Width == 0)
{
// destination width not set - calculate it based on height proportion
var scale = destination.Height / Size.Height;
destination = new Size(Size.Width * scale, destination.Height);
}
else if (destination.Height == 0)
{
// destination height not set - calculate it based on width proportion
var scale = destination.Width / Size.Width;
destination = new Size(destination.Width, Size.Height * scale);
}
#endregion

#region scale according to DPI
var di = Graphics.Display.DisplayInformation.GetForCurrentView();
var dpi = di.LogicalDpi / 96.0f;
destination = new Size(destination.Width * dpi, destination.Height * dpi);
#endregion

#region create bitmap
var bitmap = Bitmap.CreateBitmap((int)destination.Width, (int)destination.Height, Bitmap.Config.Argb8888);
RectF destinationRect = new Rect(new Foundation.Point(), destination);
#endregion

#region fill with background color
using (var canvas = new Canvas(bitmap))
{
var color = options.BackgroundColor.Equals(default(Color)) ? Colors.White : options.BackgroundColor;
canvas.DrawRect(destinationRect, new Paint()
{
Color = color
});
}
#endregion

// Render content
_pdfPage.Render(bitmap, null, null, PdfRenderMode.ForDisplay);

if (sourceRect.Width > 0 && sourceRect.Height > 0)
{
using var oldbitmap = bitmap;
bitmap = Bitmap.CreateBitmap(oldbitmap, (int)sourceRect.X, (int)sourceRect.Y, (int)sourceRect.Width, (int)sourceRect.Height, null, false);
}

return bitmap;
}

public IAsyncAction PreparePageAsync() =>
Task.CompletedTask.AsAsyncAction();

public void Dispose()
{
_pdfPage.Close();
_pdfPage.Dispose();
}
}
Loading

0 comments on commit b86d796

Please sign in to comment.