Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions Benchmark/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using BenchmarkDotNet.Running;
using FacturXDotNet;
using FacturXDotNet.Models.CII;
using FacturXDotNet.Parsing;
using FacturXDotNet.Validation;

BenchmarkRunner.Run<BenchmarkCii>();
Expand Down Expand Up @@ -44,8 +43,8 @@ public async Task ExtractCiiXml()
string sourceFilePath = GetSourceFilePath();
await using FileStream file = File.OpenRead(sourceFilePath);

FacturXParser parser = new();
_ = await parser.ParseFacturXPdfAsync(file);
FacturXDocument document = await FacturXDocument.FromFileAsync(GetSourceFilePath());
_ = document.GetCrossIndustryInvoiceAttachmentAsync();
}

[Benchmark]
Expand All @@ -54,11 +53,9 @@ public async Task ValidateFacturX()
string sourceFilePath = GetSourceFilePath();
await using FileStream file = File.OpenRead(sourceFilePath);

FacturXParser parser = new();
FacturXDocument document = await parser.ParseFacturXPdfAsync(file);

FacturXDocument document = await FacturXDocument.FromFileAsync(GetSourceFilePath());
FacturXValidator validator = new();
_ = validator.IsValid(document);
_ = await validator.IsValidAsync(document);
}

string GetSourceFilePath()
Expand Down
12 changes: 8 additions & 4 deletions FacturXDotNet.CLI/Extract/ExtractCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,19 @@ await AnsiConsole.Status()
return 0;
}

static async Task ExtractCii(FileInfo pdfPath, string? ciiAttachment, string outputPath, CancellationToken cancellationToken)
static async Task ExtractCii(FileInfo pdfPath, string? ciiAttachmentName, string outputPath, CancellationToken cancellationToken)
{
await using FileStream stream = pdfPath.OpenRead();
FacturXDocument document = await FacturXDocument.FromStream(stream, cancellationToken);

FacturXCrossIndustryInvoiceExtractor extractor = new(new FacturXCrossIndustryInvoiceExtractorOptions { CiiXmlAttachmentName = ciiAttachment });
await using Stream xmpStream = extractor.ExtractCiiAsync(stream);
CrossIndustryInvoiceAttachment? ciiAttachment = await document.GetCrossIndustryInvoiceAttachmentAsync(ciiAttachmentName, cancellationToken: cancellationToken);
if (ciiAttachment == null)
{
return;
}

await using FileStream xmpFile = File.Open(outputPath, FileMode.Create);
await xmpStream.CopyToAsync(xmpFile, cancellationToken);
await ciiAttachment.CopyToAsync(xmpFile, cancellationToken: cancellationToken);
}

static async Task ExtractXmp(FileInfo pdfPath, string outputPath, CancellationToken cancellationToken)
Expand Down
26 changes: 11 additions & 15 deletions FacturXDotNet.CLI/Validate/ValidateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using System.CommandLine.Parsing;
using System.Diagnostics;
using FacturXDotNet.Models;
using FacturXDotNet.Parsing;
using FacturXDotNet.Validation;
using Humanizer;
using Spectre.Console;
Expand Down Expand Up @@ -83,13 +82,6 @@ protected override async Task<int> RunImplAsync(ValidateCommandOptions options,
ShowOptions(options);
AnsiConsole.WriteLine();

FacturXParserOptions parserOptions = new()
{
CiiXmlAttachmentName = options.CiiAttachment
};

FacturXParser parser = new(parserOptions);

FacturXDocument? facturX = null;
await AnsiConsole.Status()
.Spinner(Spinner.Known.Default)
Expand All @@ -101,7 +93,7 @@ await AnsiConsole.Status()
sw.Start();

await using FileStream stream = options.Path.OpenRead();
facturX = await parser.ParseFacturXPdfAsync(stream);
facturX = await FacturXDocument.FromStream(stream, cancellationToken);

sw.Stop();

Expand All @@ -122,17 +114,17 @@ await AnsiConsole.Status()
validationOptions.RulesToSkip.AddRange(options.RulesToSkip);

FacturXValidationResult result = default;
AnsiConsole.Status()
await AnsiConsole.Status()
.Spinner(Spinner.Known.Default)
.Start(
.StartAsync(
"Validating...",
_ =>
async _ =>
{
Stopwatch sw = new();
sw.Start();

FacturXValidator validator = new(validationOptions);
result = validator.GetValidationResult(facturX);
result = await validator.GetValidationResultAsync(facturX, options.CiiAttachment, cancellationToken: cancellationToken);

sw.Stop();

Expand Down Expand Up @@ -184,7 +176,7 @@ static void ShowOptions(ValidateCommandOptions options)

static void ShowFinalResult(FacturXDocument document, FacturXValidationResult result)
{
FacturXProfile documentProfile = document.CrossIndustryInvoice.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile();
FacturXProfile documentProfile = result.ExpectedProfile;
FacturXProfile detectedProfile = result.ValidProfiles.GetMaxProfile();
if (result.Success)
{
Expand All @@ -200,7 +192,11 @@ static void ShowFinalResult(FacturXDocument document, FacturXValidationResult re
{
AnsiConsole.MarkupLine("[red]:cross_mark: The Factur-X docuemnt is invalid.[/]");
AnsiConsole.MarkupLine($"[red]:cross_mark: Document profile: {documentProfile}.[/]");
AnsiConsole.MarkupLine($"[red]:cross_mark: Detected profile: {detectedProfile}.[/]");

if (documentProfile != detectedProfile)
{
AnsiConsole.MarkupLine($"[red]:cross_mark: Detected profile: {detectedProfile}.[/]");
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions FacturXDotNet.sln.DotSettings.user
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APdfNameTree_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbf868f2490746fbab2087b4b2ac8198ff600_003F6f_003F97381f3b_003FPdfNameTree_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APdfString_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbf868f2490746fbab2087b4b2ac8198ff600_003F7e_003Fddd2c685_003FPdfString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReaderProperties_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbf868f2490746fbab2087b4b2ac8198ff600_003F0c_003F131a4d44_003FReaderProperties_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlyMemoryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F205abcd42df15dacc85872d4a43d4d724cfda7324c41952763ef28513b8594_003FReadOnlyMemoryExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARootCommand_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ffc4ac513e724771c6a8c5af24a64c6f0818fe32965f637b47eb49bb25f569f_003FRootCommand_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpinner_002EGenerated_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2785637749a235e9a285d99c8285b3ce12be9e079838976c3d8b3551a6eb6_003FSpinner_002EGenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASymbolResult_002Ecs_002Fl_003AC_0021_003FUsers_003Flahki_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F21317424d3675027d3784fccc07d603d802830f9542ce12a7ec94118e59e0_003FSymbolResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
Expand Down
39 changes: 39 additions & 0 deletions FacturXDotNet/CrossIndustryInvoiceAttachment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using FacturXDotNet.Parsing.CII;

namespace FacturXDotNet;

/// <summary>
/// The Cross-Industry Invoice attachment.
/// </summary>
public class CrossIndustryInvoiceAttachment : FacturXDocumentAttachment
{
/// <summary>
/// The Cross-Industry Invoice attachment.
/// </summary>
/// <param name="facturX">The Factur-X document.</param>
/// <param name="name">The name of the attachment in the Factur-X document.</param>
internal CrossIndustryInvoiceAttachment(FacturXDocument facturX, string name) : base(facturX, name)
{
}

/// <summary>
/// Get the parsed Cross-Industry Invoice.
/// </summary>
/// <param name="password">The password to open the PDF document.</param>
/// <param name="options">The options to parse the Cross-Industry Invoice.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The parsed Cross-Industry Invoice.</returns>
public async Task<CrossIndustryInvoice> GetCrossIndustryInvoiceAsync(
string? password = null,
CrossIndustryInvoiceParserOptions? options = null,
CancellationToken cancellationToken = default
)
{
options ??= new CrossIndustryInvoiceParserOptions();

await using Stream stream = await FindAttachmentStreamAsync(password, cancellationToken);

CrossIndustryInvoiceParser parser = new(options);
return parser.ParseCiiXml(stream);
}
}
149 changes: 141 additions & 8 deletions FacturXDotNet/FacturXDocument.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,155 @@
namespace FacturXDotNet;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using CommunityToolkit.HighPerformance;
using FacturXDotNet.Parsing;
using FacturXDotNet.Parsing.XMP;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;

namespace FacturXDotNet;

/// <summary>
/// A Factur-X document.
/// </summary>
public class FacturXDocument
public partial class FacturXDocument
{
/// <summary>
/// The XMP metadata of the Factur-X document.
/// Create a new Factur-X document.
/// </summary>
public FacturXDocument(ReadOnlyMemory<byte> data)
{
Data = data;
}

/// <summary>
/// The raw document.
/// </summary>
public required XmpMetadata XmpMetadata { get; set; }
public ReadOnlyMemory<byte> Data { get; }

/// <summary>
/// Information about the Cross-Industry Invoice file that was found in the Factur-X document.
/// Get the XMP metadata of the Factur-X document.
/// </summary>
public required CrossIndustryInvoiceFileInformation CrossIndustryInvoiceFileInformation { get; set; }
/// <param name="password">The password to open the PDF document.</param>
/// <param name="xmpParserOptions">The options to parse the XMP metadata.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The XMP metadata of the Factur-X document.</returns>
public async Task<XmpMetadata?> GetXmpMetadataAsync(string? password = null, XmpMetadataParserOptions? xmpParserOptions = null, CancellationToken cancellationToken = default)
{
using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken);

ExtractXmpFromFacturX extractor = new();
if (!extractor.TryExtractXmpMetadata(pdfDocument, out Stream? xmpStream))
{
return null;
}

await using Stream _ = xmpStream;

// TODO: avoid these two extra copies, it is only required because TurboXML doesn't support the <?xpacket...?> processing instructions
// an issue has been opened to address this: https://github.com/xoofx/TurboXml/issues/6
// I need to fix this in the library, but it will take some time

using StreamReader reader = new(xmpStream);
string content = await reader.ReadToEndAsync(cancellationToken);
string transformedContent = PacketInstructions().Replace(content, string.Empty);

await using MemoryStream transformedStream = new(transformedContent.Length + 54);
await using StreamWriter writer = new(transformedStream);
await writer.WriteAsync("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>");
await writer.WriteAsync(transformedContent);
await writer.FlushAsync(cancellationToken);
transformedStream.Seek(0, SeekOrigin.Begin);

XmpMetadataParser xmpParser = new(xmpParserOptions);
return xmpParser.ParseXmpMetadata(transformedStream);
}

/// <summary>
/// The Cross-Industry Invoice of the Factur-X document.
/// Get the Cross-Industry Invoice of the Factur-X document.
/// </summary>
public required CrossIndustryInvoice CrossIndustryInvoice { get; set; }
/// <param name="attachmentFileName">The name of the attachment containing the Cross-Industry Invoice XML file. If not specified, the default name 'factur-x.xml' will be used.</param>
/// <param name="password">The password to open the PDF document.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The Cross-Industry Invoice of the Factur-X document.</returns>
public async Task<CrossIndustryInvoiceAttachment?> GetCrossIndustryInvoiceAttachmentAsync(
string? attachmentFileName = null,
string? password = null,
CancellationToken cancellationToken = default
)
{
attachmentFileName ??= "factur-x.xml";
using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken);

ExtractAttachmentsFromFacturX extractor = new();
foreach ((string Name, Stream Content) attachment in extractor.ExtractFacturXAttachments(pdfDocument))
{
if (attachment.Name != attachmentFileName)
{
continue;
}

return new CrossIndustryInvoiceAttachment(this, attachmentFileName);
}

return null;
}

/// <summary>
/// Get the attachments of the Factur-X document.
/// </summary>
/// <param name="password">The password to open the PDF document.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The attachments of the Factur-X document.</returns>
public async IAsyncEnumerable<FacturXDocumentAttachment> GetAttachmentsAsync(string? password = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken);

ExtractAttachmentsFromFacturX extractor = new();
foreach ((string Name, Stream Content) attachment in extractor.ExtractFacturXAttachments(pdfDocument))
{
byte[] attachmentBytes = new byte[attachment.Content.Length];
attachment.Content.ReadExactly(attachmentBytes);
yield return new FacturXDocumentAttachment(this, attachment.Name);
}
}

internal async Task<PdfDocument> OpenPdfDocumentAsync(string? password, CancellationToken _ = default)
{
await using Stream stream = Data.AsStream();
PdfDocument document;

if (password != null)
{
document = PdfReader.Open(stream, PdfDocumentOpenMode.Import, args => args.Password = password);
}
else
{
document = PdfReader.Open(stream, PdfDocumentOpenMode.Import);
}

return document;
}

/// <summary>
/// Create a new Factur-X document from a file.
/// </summary>
public static async Task<FacturXDocument> FromFileAsync(string filePath, CancellationToken cancellationToken = default)
{
byte[] buffer = await File.ReadAllBytesAsync(filePath, cancellationToken);
return new FacturXDocument(buffer);
}

/// <summary>
/// Create a new Factur-X document from a stream.
/// </summary>
/// <remarks>This method will copy the entire stream. Consider using the <see cref="FacturXDocument(ReadOnlyMemory{byte})" /> constructor if the data is already in memory.</remarks>
public static async Task<FacturXDocument> FromStream(Stream stream, CancellationToken cancellationToken = default)
{
byte[] buffer = new byte[stream.Length];
await stream.ReadExactlyAsync(buffer, cancellationToken);
return new FacturXDocument(buffer);
}

[GeneratedRegex("<\\?xpacket.*?\\?>")]
private static partial Regex PacketInstructions();
}
Loading