mirror of
https://github.com/UglyToad/PdfPig.git
synced 2025-10-15 19:54:52 +08:00
#55 move support for images to page and add inline images
support both xobject and inline images. adds unsupported filters so that exceptions are only thrown when accessing lazily evaluated image.bytes property rather than when opening the page. treat all warnings as errors.
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
using Colors;
|
||||
using Content;
|
||||
using Core;
|
||||
using Exceptions;
|
||||
using Filters;
|
||||
using Fonts;
|
||||
using Geometry;
|
||||
using IO;
|
||||
@@ -19,27 +21,43 @@
|
||||
|
||||
internal class ContentStreamProcessor : IOperationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores each letter as it is encountered in the content stream.
|
||||
/// </summary>
|
||||
private readonly List<Letter> letters = new List<Letter>();
|
||||
|
||||
/// <summary>
|
||||
/// Stores each path as it is encountered in the content stream.
|
||||
/// </summary>
|
||||
private readonly List<PdfPath> paths = new List<PdfPath>();
|
||||
|
||||
/// <summary>
|
||||
/// Stores a link to each image (either inline or XObject) as it is encountered in the content stream.
|
||||
/// </summary>
|
||||
private readonly List<Union<XObjectContentRecord, InlineImage>> images = new List<Union<XObjectContentRecord, InlineImage>>();
|
||||
|
||||
private readonly IResourceStore resourceStore;
|
||||
private readonly UserSpaceUnit userSpaceUnit;
|
||||
private readonly PageRotationDegrees rotation;
|
||||
private readonly bool isLenientParsing;
|
||||
private readonly IPdfTokenScanner pdfScanner;
|
||||
private readonly XObjectFactory xObjectFactory;
|
||||
private readonly IFilterProvider filterProvider;
|
||||
private readonly ILog log;
|
||||
|
||||
private Stack<CurrentGraphicsState> graphicsStack = new Stack<CurrentGraphicsState>();
|
||||
private IFont activeExtendedGraphicsStateFont = null;
|
||||
private IFont activeExtendedGraphicsStateFont;
|
||||
private InlineImageBuilder inlineImageBuilder;
|
||||
|
||||
//a sequence number of ShowText operation to determine whether letters belong to same operation or not (letters that belong to different operations have less changes to belong to same word)
|
||||
private int textSequence = 0;
|
||||
/// <summary>
|
||||
/// A counter to track individual calls to <see cref="ShowText"/> operations used to determine if letters are likely to be
|
||||
/// in the same word/group. This exposes internal grouping of letters used by the PDF creator which may correspond to the
|
||||
/// intended grouping of letters into words.
|
||||
/// </summary>
|
||||
private int textSequence;
|
||||
|
||||
public TextMatrices TextMatrices { get; } = new TextMatrices();
|
||||
|
||||
public TransformationMatrix CurrentTransformationMatrix
|
||||
{
|
||||
get { return GetCurrentState().CurrentTransformationMatrix; }
|
||||
}
|
||||
public TransformationMatrix CurrentTransformationMatrix => GetCurrentState().CurrentTransformationMatrix;
|
||||
|
||||
public PdfPath CurrentPath { get; private set; }
|
||||
|
||||
@@ -56,18 +74,18 @@
|
||||
{XObjectType.PostScript, new List<XObjectContentRecord>()}
|
||||
};
|
||||
|
||||
public List<Letter> Letters = new List<Letter>();
|
||||
public ContentStreamProcessor(PdfRectangle cropBox, IResourceStore resourceStore, UserSpaceUnit userSpaceUnit, PageRotationDegrees rotation, bool isLenientParsing,
|
||||
public ContentStreamProcessor(PdfRectangle cropBox, IResourceStore resourceStore, UserSpaceUnit userSpaceUnit, PageRotationDegrees rotation,
|
||||
bool isLenientParsing,
|
||||
IPdfTokenScanner pdfScanner,
|
||||
XObjectFactory xObjectFactory,
|
||||
IFilterProvider filterProvider,
|
||||
ILog log)
|
||||
{
|
||||
this.resourceStore = resourceStore;
|
||||
this.userSpaceUnit = userSpaceUnit;
|
||||
this.rotation = rotation;
|
||||
this.isLenientParsing = isLenientParsing;
|
||||
this.pdfScanner = pdfScanner;
|
||||
this.xObjectFactory = xObjectFactory;
|
||||
this.pdfScanner = pdfScanner ?? throw new ArgumentNullException(nameof(pdfScanner));
|
||||
this.filterProvider = filterProvider ?? throw new ArgumentNullException(nameof(filterProvider));
|
||||
this.log = log;
|
||||
graphicsStack.Push(new CurrentGraphicsState());
|
||||
ColorSpaceContext = new ColorSpaceContext(GetCurrentState);
|
||||
@@ -75,11 +93,11 @@
|
||||
|
||||
public PageContent Process(IReadOnlyList<IGraphicsStateOperation> operations)
|
||||
{
|
||||
var currentState = CloneAllStates();
|
||||
CloneAllStates();
|
||||
|
||||
ProcessOperations(operations);
|
||||
|
||||
return new PageContent(operations, Letters, paths, xObjects, pdfScanner, xObjectFactory, isLenientParsing);
|
||||
return new PageContent(operations, letters, paths, images, pdfScanner, filterProvider, resourceStore, isLenientParsing);
|
||||
}
|
||||
|
||||
private void ProcessOperations(IReadOnlyList<IGraphicsStateOperation> operations)
|
||||
@@ -265,7 +283,7 @@
|
||||
var xObjectStream = resourceStore.GetXObject(xObjectName);
|
||||
|
||||
// For now we will determine the type and store the object with the graphics state information preceding it.
|
||||
// Then consumers of the page can request the object/s to be retrieved by type.
|
||||
// Then consumers of the page can request the object(s) to be retrieved by type.
|
||||
var subType = (NameToken)xObjectStream.StreamDictionary.Data[NameToken.Subtype.Data];
|
||||
|
||||
var state = GetCurrentState();
|
||||
@@ -274,15 +292,15 @@
|
||||
|
||||
if (subType.Equals(NameToken.Ps))
|
||||
{
|
||||
xObjects[XObjectType.PostScript].Add(new XObjectContentRecord(XObjectType.PostScript, xObjectStream, matrix));
|
||||
xObjects[XObjectType.PostScript].Add(new XObjectContentRecord(XObjectType.PostScript, xObjectStream, matrix, state.RenderingIntent));
|
||||
}
|
||||
else if (subType.Equals(NameToken.Image))
|
||||
{
|
||||
xObjects[XObjectType.Image].Add(new XObjectContentRecord(XObjectType.Image, xObjectStream, matrix));
|
||||
images.Add(Union<XObjectContentRecord, InlineImage>.One(new XObjectContentRecord(XObjectType.Image, xObjectStream, matrix, state.RenderingIntent)));
|
||||
}
|
||||
else if (subType.Equals(NameToken.Form))
|
||||
{
|
||||
xObjects[XObjectType.Form].Add(new XObjectContentRecord(XObjectType.Form, xObjectStream, matrix));
|
||||
xObjects[XObjectType.Form].Add(new XObjectContentRecord(XObjectType.Form, xObjectStream, matrix, state.RenderingIntent));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -361,6 +379,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
public void BeginInlineImage()
|
||||
{
|
||||
if (inlineImageBuilder != null && !isLenientParsing)
|
||||
{
|
||||
throw new PdfDocumentFormatException("Begin inline image (BI) command encountered while another inline image was active.");
|
||||
}
|
||||
|
||||
inlineImageBuilder = new InlineImageBuilder();
|
||||
}
|
||||
|
||||
public void SetInlineImageProperties(IReadOnlyDictionary<NameToken, IToken> properties)
|
||||
{
|
||||
if (inlineImageBuilder == null)
|
||||
{
|
||||
if (isLenientParsing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new PdfDocumentFormatException("Begin inline image data (ID) command encountered without a corresponding begin inline image (BI) command.");
|
||||
}
|
||||
|
||||
inlineImageBuilder.Properties = properties;
|
||||
}
|
||||
|
||||
public void EndInlineImage(IReadOnlyList<byte> bytes)
|
||||
{
|
||||
if (inlineImageBuilder == null)
|
||||
{
|
||||
if (isLenientParsing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new PdfDocumentFormatException("End inline image (EI) command encountered without a corresponding begin inline image (BI) command.");
|
||||
}
|
||||
|
||||
inlineImageBuilder.Bytes = bytes;
|
||||
|
||||
var image = inlineImageBuilder.CreateInlineImage(CurrentTransformationMatrix, filterProvider, pdfScanner, GetCurrentState().RenderingIntent, resourceStore);
|
||||
|
||||
images.Add(Union<XObjectContentRecord, InlineImage>.Two(image));
|
||||
|
||||
inlineImageBuilder = null;
|
||||
}
|
||||
|
||||
private void AdjustTextMatrix(decimal tx, decimal ty)
|
||||
{
|
||||
var matrix = TransformationMatrix.GetTranslationMatrix(tx, ty);
|
||||
@@ -390,7 +454,7 @@
|
||||
pointSize,
|
||||
textSequence);
|
||||
|
||||
Letters.Add(letter);
|
||||
letters.Add(letter);
|
||||
}
|
||||
}
|
||||
}
|
@@ -4,7 +4,7 @@
|
||||
using Geometry;
|
||||
using IO;
|
||||
using Tokens;
|
||||
using UglyToad.PdfPig.Core;
|
||||
using PdfPig.Core;
|
||||
using Util.JetBrains.Annotations;
|
||||
|
||||
/// <summary>
|
||||
@@ -104,5 +104,20 @@
|
||||
/// </summary>
|
||||
/// <param name="stateName">The name of the state to apply.</param>
|
||||
void SetNamedGraphicsState(NameToken stateName);
|
||||
|
||||
/// <summary>
|
||||
/// Indicate that an inline image is being defined.
|
||||
/// </summary>
|
||||
void BeginInlineImage();
|
||||
|
||||
/// <summary>
|
||||
/// Define the properties of the inline image currently being drawn.
|
||||
/// </summary>
|
||||
void SetInlineImageProperties(IReadOnlyDictionary<NameToken, IToken> properties);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the current inline image is complete.
|
||||
/// </summary>
|
||||
void EndInlineImage(IReadOnlyList<byte> bytes);
|
||||
}
|
||||
}
|
213
src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs
Normal file
213
src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace UglyToad.PdfPig.Graphics
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Colors;
|
||||
using Content;
|
||||
using Core;
|
||||
using Exceptions;
|
||||
using Filters;
|
||||
using Geometry;
|
||||
using PdfPig.Core;
|
||||
using Tokenization.Scanner;
|
||||
using Tokens;
|
||||
using Util;
|
||||
|
||||
internal class InlineImageBuilder
|
||||
{
|
||||
public IReadOnlyDictionary<NameToken, IToken> Properties { get; set; }
|
||||
|
||||
public IReadOnlyList<byte> Bytes { get; set; }
|
||||
|
||||
public InlineImage CreateInlineImage(TransformationMatrix transformationMatrix, IFilterProvider filterProvider,
|
||||
IPdfTokenScanner tokenScanner,
|
||||
RenderingIntent defaultRenderingIntent,
|
||||
IResourceStore resourceStore)
|
||||
{
|
||||
if (Properties == null || Bytes == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Inline image builder not completely defined before calling {nameof(CreateInlineImage)}.");
|
||||
}
|
||||
|
||||
bool TryMapColorSpace(NameToken name, out ColorSpace colorSpaceResult)
|
||||
{
|
||||
if (name.TryMapToColorSpace(out colorSpaceResult))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryExtendedColorSpaceNameMapping(name, out colorSpaceResult))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resourceStore.TryGetNamedColorSpace(name, out var colorSpaceNamedToken) || !(colorSpaceNamedToken is NameToken newName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newName.TryMapToColorSpace(out colorSpaceResult))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryExtendedColorSpaceNameMapping(newName, out colorSpaceResult))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var bounds = transformationMatrix.Transform(new PdfRectangle(new PdfPoint(1, 1),
|
||||
new PdfPoint(0, 0)));
|
||||
|
||||
var width = GetByKeys<NumericToken>(NameToken.Width, NameToken.W, true).Int;
|
||||
|
||||
var height = GetByKeys<NumericToken>(NameToken.Height, NameToken.H, true).Int;
|
||||
|
||||
var maskToken = GetByKeys<BooleanToken>(NameToken.ImageMask, NameToken.Im, false);
|
||||
|
||||
var isMask = maskToken?.Data == true;
|
||||
|
||||
var bitsPerComponent = GetByKeys<NumericToken>(NameToken.BitsPerComponent, NameToken.Bpc, !isMask)?.Int ?? 1;
|
||||
|
||||
var colorSpace = default(ColorSpace?);
|
||||
|
||||
if (!isMask)
|
||||
{
|
||||
var colorSpaceName = GetByKeys<NameToken>(NameToken.ColorSpace, NameToken.Cs, false);
|
||||
|
||||
if (colorSpaceName == null)
|
||||
{
|
||||
var colorSpaceArray = GetByKeys<ArrayToken>(NameToken.ColorSpace, NameToken.Cs, true);
|
||||
|
||||
if (colorSpaceArray.Length == 0)
|
||||
{
|
||||
throw new PdfDocumentFormatException("Empty ColorSpace array defined for inline image.");
|
||||
}
|
||||
|
||||
if (!(colorSpaceArray.Data[0] is NameToken firstColorSpaceName))
|
||||
{
|
||||
throw new PdfDocumentFormatException($"Invalid ColorSpace array defined for inline image: {colorSpaceArray}.");
|
||||
}
|
||||
|
||||
if (!TryMapColorSpace(firstColorSpaceName, out var colorSpaceMapped))
|
||||
{
|
||||
throw new PdfDocumentFormatException($"Invalid ColorSpace defined for inline image: {firstColorSpaceName}.");
|
||||
}
|
||||
|
||||
colorSpace = colorSpaceMapped;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryMapColorSpace(colorSpaceName, out var colorSpaceMapped))
|
||||
{
|
||||
throw new PdfDocumentFormatException($"Invalid ColorSpace defined for inline image: {colorSpaceName}.");
|
||||
}
|
||||
|
||||
colorSpace = colorSpaceMapped;
|
||||
}
|
||||
}
|
||||
|
||||
var renderingIntent = GetByKeys<NameToken>(NameToken.Intent, null, false)?.Data?.ToRenderingIntent() ?? defaultRenderingIntent;
|
||||
|
||||
var filterNames = new List<NameToken>();
|
||||
|
||||
var filterName = GetByKeys<NameToken>(NameToken.Filter, NameToken.F, false);
|
||||
|
||||
if (filterName == null)
|
||||
{
|
||||
var filterArray = GetByKeys<ArrayToken>(NameToken.Filter, NameToken.F, false);
|
||||
|
||||
if (filterArray != null)
|
||||
{
|
||||
filterNames.AddRange(filterArray.Data.OfType<NameToken>());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
filterNames.Add(filterName);
|
||||
}
|
||||
|
||||
var filters = filterProvider.GetNamedFilters(filterNames);
|
||||
|
||||
var decodeRaw = GetByKeys<ArrayToken>(NameToken.Decode, NameToken.D, false) ?? new ArrayToken(EmptyArray<IToken>.Instance);
|
||||
|
||||
var decode = decodeRaw.Data.OfType<NumericToken>().Select(x => x.Data).ToArray();
|
||||
|
||||
var filterDictionaryEntries = new Dictionary<NameToken, IToken>();
|
||||
var decodeParamsDict = GetByKeys<DictionaryToken>(NameToken.DecodeParms, NameToken.Dp, false);
|
||||
|
||||
if (decodeParamsDict == null)
|
||||
{
|
||||
var decodeParamsArray = GetByKeys<ArrayToken>(NameToken.DecodeParms, NameToken.Dp, false);
|
||||
|
||||
if (decodeParamsArray != null)
|
||||
{
|
||||
filterDictionaryEntries[NameToken.DecodeParms] = decodeParamsArray;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
filterDictionaryEntries[NameToken.DecodeParms] = decodeParamsDict;
|
||||
}
|
||||
|
||||
var streamDictionary = new DictionaryToken(filterDictionaryEntries);
|
||||
|
||||
var interpolate = GetByKeys<BooleanToken>(NameToken.Interpolate, NameToken.I, false)?.Data ?? false;
|
||||
|
||||
return new InlineImage(bounds, width, height, bitsPerComponent, isMask, renderingIntent, interpolate, colorSpace, decode, Bytes,
|
||||
filters,
|
||||
streamDictionary);
|
||||
}
|
||||
|
||||
private static bool TryExtendedColorSpaceNameMapping(NameToken name, out ColorSpace result)
|
||||
{
|
||||
result = ColorSpace.DeviceGray;
|
||||
|
||||
switch (name.Data)
|
||||
{
|
||||
case "G":
|
||||
result = ColorSpace.DeviceGray;
|
||||
return true;
|
||||
case "RGB":
|
||||
result = ColorSpace.DeviceRGB;
|
||||
return true;
|
||||
case "CMYK":
|
||||
result = ColorSpace.DeviceCMYK;
|
||||
return true;
|
||||
case "I":
|
||||
result = ColorSpace.Indexed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local
|
||||
private T GetByKeys<T>(NameToken name1, NameToken name2, bool required) where T : IToken
|
||||
{
|
||||
if (Properties.TryGetValue(name1, out var val) && val is T result)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (name2 != null)
|
||||
{
|
||||
if (Properties.TryGetValue(name2, out val) && val is T result2)
|
||||
{
|
||||
return result2;
|
||||
}
|
||||
}
|
||||
|
||||
if (required)
|
||||
{
|
||||
throw new PdfDocumentFormatException($"Inline image dictionary missing required entry {name1}/{name2}.");
|
||||
}
|
||||
|
||||
return default(T);
|
||||
}
|
||||
}
|
||||
}
|
@@ -28,6 +28,7 @@
|
||||
/// <inheritdoc />
|
||||
public void Run(IOperationContext operationContext)
|
||||
{
|
||||
operationContext.BeginInlineImage();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@@ -1,6 +1,9 @@
|
||||
namespace UglyToad.PdfPig.Graphics.Operations.InlineImages
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Tokens;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
@@ -12,22 +15,27 @@
|
||||
/// The symbol for this operation in a stream.
|
||||
/// </summary>
|
||||
public const string Symbol = "ID";
|
||||
|
||||
/// <summary>
|
||||
/// The instance of the <see cref="BeginInlineImageData"/> operation.
|
||||
/// </summary>
|
||||
public static readonly BeginInlineImageData Value = new BeginInlineImageData();
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Operator => Symbol;
|
||||
|
||||
private BeginInlineImageData()
|
||||
/// <summary>
|
||||
/// The key-value pairs which specify attributes of the following image.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<NameToken, IToken> Dictionary { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="BeginInlineImageData"/>.
|
||||
/// </summary>
|
||||
public BeginInlineImageData(IReadOnlyDictionary<NameToken, IToken> dictionary)
|
||||
{
|
||||
Dictionary = dictionary ?? throw new ArgumentNullException(nameof(dictionary));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IOperationContext operationContext)
|
||||
{
|
||||
operationContext.SetInlineImageProperties(Dictionary);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Tokens;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
@@ -15,14 +14,9 @@
|
||||
/// The symbol for this operation in a stream.
|
||||
/// </summary>
|
||||
public const string Symbol = "EI";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The tokens declared in order for this inline image object.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IToken> ImageTokens { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw data for the inline image which should be interpreted according to the <see cref="ImageTokens"/>.
|
||||
/// The raw data for the inline image which should be interpreted according to the corresponding <see cref="BeginInlineImageData.Dictionary"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<byte> ImageData { get; }
|
||||
|
||||
@@ -32,17 +26,16 @@
|
||||
/// <summary>
|
||||
/// Create a new <see cref="EndInlineImage"/> operation.
|
||||
/// </summary>
|
||||
/// <param name="imageTokens">The tokens which were set during the declaration of this image.</param>
|
||||
/// <param name="imageData">The raw byte data of this image.</param>
|
||||
public EndInlineImage(IReadOnlyList<IToken> imageTokens, IReadOnlyList<byte> imageData)
|
||||
public EndInlineImage(IReadOnlyList<byte> imageData)
|
||||
{
|
||||
ImageTokens = imageTokens ?? throw new ArgumentNullException(nameof(imageTokens));
|
||||
ImageData = imageData ?? throw new ArgumentNullException(nameof(imageData));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IOperationContext operationContext)
|
||||
{
|
||||
operationContext.EndInlineImage(ImageData);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@@ -1,6 +1,7 @@
|
||||
namespace UglyToad.PdfPig.Graphics
|
||||
{
|
||||
using System;
|
||||
using Core;
|
||||
using PdfPig.Core;
|
||||
using Tokens;
|
||||
using Util.JetBrains.Annotations;
|
||||
@@ -15,11 +16,15 @@
|
||||
|
||||
public TransformationMatrix AppliedTransformation { get; }
|
||||
|
||||
public XObjectContentRecord(XObjectType type, StreamToken stream, TransformationMatrix appliedTransformation)
|
||||
public RenderingIntent DefaultRenderingIntent { get; }
|
||||
|
||||
public XObjectContentRecord(XObjectType type, StreamToken stream, TransformationMatrix appliedTransformation,
|
||||
RenderingIntent defaultRenderingIntent)
|
||||
{
|
||||
Type = type;
|
||||
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
AppliedTransformation = appliedTransformation;
|
||||
DefaultRenderingIntent = defaultRenderingIntent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user