From 41d32371e797d07240dbb665194cd458620ec663 Mon Sep 17 00:00:00 2001 From: ilaurillard Date: Thu, 28 Nov 2024 11:54:21 +0100 Subject: [PATCH] PDF/A 3b (#1750) * PDF/A 3b * PDF/A 3b * PDF/A, reorganized into multiple files * PDF/A, make all annotations printable as a default (for pdf/a) * PDF/A, merging /Names when attaching files * PDF/A, extended facturx-rdf * Update pdf/lib/src/pdf/obj/pdfa/README.md Co-authored-by: Colin Ihlenfeldt --------- Co-authored-by: David PHAM-VAN <1387855+DavBfr@users.noreply.github.com> Co-authored-by: Colin Ihlenfeldt --- pdf/lib/pdf.dart | 4 + pdf/lib/src/pdf/obj/annotation.dart | 16 +- pdf/lib/src/pdf/obj/catalog.dart | 19 ++ pdf/lib/src/pdf/obj/metadata.dart | 3 +- pdf/lib/src/pdf/obj/pdfa/README.md | 40 +++++ .../src/pdf/obj/pdfa/pdfa_attached_files.dart | 163 ++++++++++++++++++ .../src/pdf/obj/pdfa/pdfa_color_profile.dart | 49 ++++++ .../src/pdf/obj/pdfa/pdfa_date_format.dart | 20 +++ .../src/pdf/obj/pdfa/pdfa_facturx_rdf.dart | 64 +++++++ pdf/lib/src/pdf/obj/pdfa/pdfa_rdf.dart | 68 ++++++++ pdf/lib/src/pdf/obj/pdfa/sRGB2014.icc | Bin 0 -> 3024 bytes 11 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 pdf/lib/src/pdf/obj/pdfa/README.md create mode 100644 pdf/lib/src/pdf/obj/pdfa/pdfa_attached_files.dart create mode 100644 pdf/lib/src/pdf/obj/pdfa/pdfa_color_profile.dart create mode 100644 pdf/lib/src/pdf/obj/pdfa/pdfa_date_format.dart create mode 100644 pdf/lib/src/pdf/obj/pdfa/pdfa_facturx_rdf.dart create mode 100644 pdf/lib/src/pdf/obj/pdfa/pdfa_rdf.dart create mode 100644 pdf/lib/src/pdf/obj/pdfa/sRGB2014.icc diff --git a/pdf/lib/pdf.dart b/pdf/lib/pdf.dart index c83b9e44..da4ebef1 100644 --- a/pdf/lib/pdf.dart +++ b/pdf/lib/pdf.dart @@ -37,6 +37,10 @@ export 'src/pdf/obj/outline.dart'; export 'src/pdf/obj/page.dart'; export 'src/pdf/obj/page_label.dart'; export 'src/pdf/obj/pattern.dart'; +export 'src/pdf/obj/pdfa/pdfa_attached_files.dart'; +export 'src/pdf/obj/pdfa/pdfa_color_profile.dart'; +export 'src/pdf/obj/pdfa/pdfa_facturx_rdf.dart'; +export 'src/pdf/obj/pdfa/pdfa_rdf.dart'; export 'src/pdf/obj/shading.dart'; export 'src/pdf/obj/signature.dart'; export 'src/pdf/obj/smask.dart'; diff --git a/pdf/lib/src/pdf/obj/annotation.dart b/pdf/lib/src/pdf/obj/annotation.dart index 1a15bc13..f1bc3bb7 100644 --- a/pdf/lib/src/pdf/obj/annotation.dart +++ b/pdf/lib/src/pdf/obj/annotation.dart @@ -173,12 +173,16 @@ abstract class PdfAnnotBase { this.border, this.content, this.name, - this.flags, + Set? flags, this.date, this.color, this.subject, this.author, - }); + }) { + this.flags = flags ?? { + PdfAnnotFlags.print, + }; + } /// The subtype of the outline, ie text, note, etc final String subtype; @@ -201,7 +205,7 @@ abstract class PdfAnnotBase { final String? subject; /// Flags specifying various characteristics of the annotation - final Set? flags; + late final Set flags; /// Last modification date final DateTime? date; @@ -214,11 +218,11 @@ abstract class PdfAnnotBase { PdfName? _as; int get flagValue { - if (flags == null || flags!.isEmpty) { + if (flags.isEmpty) { return 0; } - return flags! + return flags .map((PdfAnnotFlags e) => 1 << e.index) .reduce((int a, int b) => a | b); } @@ -296,7 +300,7 @@ abstract class PdfAnnotBase { params['/NM'] = PdfString.fromString(name!); } - if (flags != null && flags!.isNotEmpty) { + if (flags.isNotEmpty) { params['/F'] = PdfNum(flagValue); } diff --git a/pdf/lib/src/pdf/obj/catalog.dart b/pdf/lib/src/pdf/obj/catalog.dart index 79f6e88b..3d4ee336 100644 --- a/pdf/lib/src/pdf/obj/catalog.dart +++ b/pdf/lib/src/pdf/obj/catalog.dart @@ -26,6 +26,8 @@ import 'object.dart'; import 'outline.dart'; import 'page_label.dart'; import 'page_list.dart'; +import 'pdfa/pdfa_attached_files.dart'; +import 'pdfa/pdfa_color_profile.dart'; /// Pdf Catalog object class PdfCatalog extends PdfObject { @@ -54,6 +56,12 @@ class PdfCatalog extends PdfObject { /// The document metadata PdfMetadata? metadata; + /// Colorprofile output intent (Pdf/A) + PdfaColorProfile? colorProfile; + + /// Attached files (Pdf/A 3b) + PdfaAttachedFiles? attached; + /// The initial page mode final PdfPageMode? pageMode; @@ -89,6 +97,13 @@ class PdfCatalog extends PdfObject { params['/Metadata'] = metadata!.ref(); } + if (attached != null && attached!.isNotEmpty) { + //params['/Names'] = attached!.catalogNames(); + names ??= PdfNames(pdfDocument); + names!.params.merge(attached!.catalogNames()); + params['/AF'] = attached!.catalogAF(); + } + // the Names object if (names != null) { params['/Names'] = names!.ref(); @@ -158,5 +173,9 @@ class PdfCatalog extends PdfObject { {'/Font': fontRefs}); } } + + if (colorProfile != null) { + params['/OutputIntents'] = colorProfile!.outputIntents(); + } } } diff --git a/pdf/lib/src/pdf/obj/metadata.dart b/pdf/lib/src/pdf/obj/metadata.dart index 5ccbdcfc..422b701f 100644 --- a/pdf/lib/src/pdf/obj/metadata.dart +++ b/pdf/lib/src/pdf/obj/metadata.dart @@ -45,7 +45,8 @@ class PdfMetadata extends PdfObject { @override void prepare() { super.prepare(); - params['/SubType'] = const PdfName('/XML'); + params['/Type'] = const PdfName('/Metadata'); + params['/Subtype'] = const PdfName('/XML'); params.data = Uint8List.fromList(utf8.encode(metadata.toString())); } } diff --git a/pdf/lib/src/pdf/obj/pdfa/README.md b/pdf/lib/src/pdf/obj/pdfa/README.md new file mode 100644 index 00000000..948ebcce --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/README.md @@ -0,0 +1,40 @@ +Here are some classes to help you creating PDF/A compliant PDFs +plus embedding Facturx invoices. + +### Rules + +1. Your PDF must only use embedded Fonts, +2. For now you cannot use any Annotations in your PDF +3. You must include a special Meta-XML, use below "PdfaRdf" and put the reuslting XML document into your documents metadata +4. You must include a Colorprofile, use the below "PdfaColorProfile" and embed the contents of "sRGB2014.icc" +5. Optionally attach an InvoiceXML using "PdfaFacturxRdf" and "PdfaAttachedFiles" + +### Example + +``` +pw.Document pdf = pw.Document( + ... + metadata: PdfaRdf( + ... + invoiceRdf: PdfaFacturxRdf().create() + ).create(), +); + +PdfaColorProfile( + pdf.document, + File('sRGB2014.icc').readAsBytesSync(), +); + +PdfaAttachedFiles( + pdf.document, + { + 'factur-x.xml': myInvoiceXmlDocument, + }, +); +``` + +### Validating + +https://demo.verapdf.org +https://avepdf.com/pdfa-validation +https://www.mustangproject.org diff --git a/pdf/lib/src/pdf/obj/pdfa/pdfa_attached_files.dart b/pdf/lib/src/pdf/obj/pdfa/pdfa_attached_files.dart new file mode 100644 index 00000000..d69d58d2 --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/pdfa_attached_files.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../../document.dart'; +import '../../format/array.dart'; +import '../../format/base.dart'; +import '../../format/dict.dart'; +import '../../format/dict_stream.dart'; +import '../../format/indirect.dart'; +import '../../format/name.dart'; +import '../../format/num.dart'; +import '../../format/object_base.dart'; +import '../../format/stream.dart'; +import '../../format/string.dart'; +import '../object.dart'; +import 'pdfa_date_format.dart'; + +class PdfaAttachedFiles { + PdfaAttachedFiles( + PdfDocument pdfDocument, + Map files, + ) { + for (var entry in files.entries) { + _files.add( + _AttachedFileSpec( + pdfDocument, + _AttachedFile( + pdfDocument, + entry.key, + entry.value, + ), + ), + ); + } + _names = _AttachedFileNames( + pdfDocument, + _files, + ); + pdfDocument.catalog.attached = this; + } + + final List<_AttachedFileSpec> _files = []; + + late final _AttachedFileNames _names; + + bool get isNotEmpty => _files.isNotEmpty; + + PdfDict catalogNames() { + return PdfDict({ + '/EmbeddedFiles': _names.ref(), + }); + } + + PdfArray catalogAF() { + final tmp = []; + for (var spec in _files) { + tmp.add(spec.ref()); + } + return PdfArray(tmp); + } +} + +class _AttachedFileNames extends PdfObject { + _AttachedFileNames( + PdfDocument pdfDocument, + this._files, + ) : super( + pdfDocument, + params: PdfDict(), + ); + final List<_AttachedFileSpec> _files; + + @override + void prepare() { + super.prepare(); + params['/Names'] = PdfArray( + [ + _PdfRaw(0, _files.first), + ], + ); + } +} + +class _AttachedFileSpec extends PdfObject { + _AttachedFileSpec( + PdfDocument pdfDocument, + this._file, + ) : super( + pdfDocument, + params: PdfDict(), + ); + final _AttachedFile _file; + + @override + void prepare() { + super.prepare(); + + params['/Type'] = const PdfName('/Filespec'); + params['/F'] = PdfString( + Uint8List.fromList(_file.fileName.codeUnits), + ); + params['/UF'] = PdfString( + Uint8List.fromList(_file.fileName.codeUnits), + ); + params['/EF'] = PdfDict({ + '/F': _file.ref(), + }); + params['/AFRelationship'] = const PdfName('/Unspecified'); + } +} + +class _AttachedFile extends PdfObject { + _AttachedFile( + PdfDocument pdfDocument, + this.fileName, + this.content, + ) : super( + pdfDocument, + params: PdfDictStream( + compress: false, + encrypt: false, + ), + ); + + final String fileName; + final String content; + + @override + void prepare() { + super.prepare(); + + final modDate = PdfaDateFormat().format(dt: DateTime.now()); + params['/Type'] = const PdfName('/EmbeddedFile'); + params['/Subtype'] = const PdfName('/application/octet-stream'); + params['/Params'] = PdfDict({ + '/Size': PdfNum(content.codeUnits.length), + '/ModDate': PdfString( + Uint8List.fromList('D:$modDate+00\'00\''.codeUnits), + ), + }); + + params.data = Uint8List.fromList(utf8.encode(content)); + } +} + +class _PdfRaw extends PdfDataType { + const _PdfRaw( + this.nr, + this.spec, + ); + + final int nr; + final _AttachedFileSpec spec; + + @override + void output( + PdfObjectBase o, + PdfStream s, [ + int? indent, + ]) { + s.putString('(${nr.toString().padLeft(3, '0')}) ${spec.ref()}'); + } +} diff --git a/pdf/lib/src/pdf/obj/pdfa/pdfa_color_profile.dart b/pdf/lib/src/pdf/obj/pdfa/pdfa_color_profile.dart new file mode 100644 index 00000000..c19c721c --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/pdfa_color_profile.dart @@ -0,0 +1,49 @@ +import 'dart:typed_data'; + +import '../../document.dart'; +import '../../format/array.dart'; +import '../../format/dict.dart'; +import '../../format/dict_stream.dart'; +import '../../format/name.dart'; +import '../../format/num.dart'; +import '../../format/string.dart'; +import '../object.dart'; + +class PdfaColorProfile extends PdfObject { + PdfaColorProfile( + PdfDocument pdfDocument, + this.icc, + ) : super( + pdfDocument, + params: PdfDictStream( + compress: false, + encrypt: false, + ), + ) { + pdfDocument.catalog.colorProfile = this; + } + + final Uint8List icc; + + @override + void prepare() { + super.prepare(); + params['/N'] = const PdfNum(3); + params.data = icc; + } + + PdfArray outputIntents() { + return PdfArray([ + PdfDict({ + '/Type': const PdfName('/OutputIntent'), + '/S': const PdfName('/GTS_PDFA1'), + '/OutputConditionIdentifier': + PdfString(Uint8List.fromList('sRGB2014.icc'.codeUnits)), + '/Info': PdfString(Uint8List.fromList('sRGB2014.icc'.codeUnits)), + '/RegistryName': + PdfString(Uint8List.fromList('http://www.color.org'.codeUnits)), + '/DestOutputProfile': ref(), + }), + ]); + } +} diff --git a/pdf/lib/src/pdf/obj/pdfa/pdfa_date_format.dart b/pdf/lib/src/pdf/obj/pdfa/pdfa_date_format.dart new file mode 100644 index 00000000..4ad4f233 --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/pdfa_date_format.dart @@ -0,0 +1,20 @@ +class PdfaDateFormat { + String format({ + required DateTime dt, + bool asIso = false, + }) { + final year = dt.year.toString().padLeft(4, '0'); + final month = dt.month.toString().padLeft(2, '0'); + final day = dt.day.toString().padLeft(2, '0'); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + final second = dt.second.toString().padLeft(2, '0'); + + if (asIso) { + // "yyyy-MM-dd'T'HH:mm:ss" + return '$year-$month-${day}T$hour:$minute:$second'; + } + // "yyyyMMddHHmmss" + return '$year$month$day$hour$minute$second'; + } +} diff --git a/pdf/lib/src/pdf/obj/pdfa/pdfa_facturx_rdf.dart b/pdf/lib/src/pdf/obj/pdfa/pdfa_facturx_rdf.dart new file mode 100644 index 00000000..aca78d09 --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/pdfa_facturx_rdf.dart @@ -0,0 +1,64 @@ +class PdfaFacturxRdf { + String create({ + String filename = 'factur-x.xml', + String namespace = 'urn:cen.eu:invoice:1p0:schema#', + String conformanceLevel = 'BASIC', + String version = '1.0', + }) { + return ''' + + + INVOICE + $filename + $version + $conformanceLevel + + + + + + + Invoice PDFA Extension Schema + $namespace + fx + + + + DocumentFileName + Text + external + name of the embedded XML invoice file + + + DocumentType + Text + external + INVOICE + + + Version + Text + external + The actual version of the ZUGFeRD data + + + ConformanceLevel + Text + external + The conformance level of the ZUGFeRD data + + + + + + + +'''; + } +} diff --git a/pdf/lib/src/pdf/obj/pdfa/pdfa_rdf.dart b/pdf/lib/src/pdf/obj/pdfa/pdfa_rdf.dart new file mode 100644 index 00000000..d5070e48 --- /dev/null +++ b/pdf/lib/src/pdf/obj/pdfa/pdfa_rdf.dart @@ -0,0 +1,68 @@ +import 'package:xml/xml.dart'; + +import 'pdfa_date_format.dart'; + +class PdfaRdf { + PdfaRdf({ + this.title, + this.author, + this.creator, + this.subject, + this.keywords, + this.producer, + DateTime? creationDate, + this.invoiceRdf = '', + }) { + this.creationDate = creationDate ?? DateTime.now(); + } + + final String? title; + final String? author; + final String? creator; + final String? subject; + final String? keywords; + final String? producer; + late final DateTime creationDate; + final String invoiceRdf; + + XmlDocument? create() { + var createDate = PdfaDateFormat().format(dt: creationDate, asIso: true); + final offset = creationDate.timeZoneOffset; + final hours = + offset.inHours > 0 ? offset.inHours : 1; // For fixing divide by 0 + if (!offset.isNegative) { + createDate = + "$createDate+${offset.inHours.toString().padLeft(2, '0')}:${(offset.inMinutes % (hours * 60)).toString().padLeft(2, '0')}"; + } else { + createDate = + "$createDate-${(-offset.inHours).toString().padLeft(2, '0')}:${(offset.inMinutes % (hours * 60)).toString().padLeft(2, '0')}"; + } + + return XmlDocument.parse(''' + + + + $producer + $keywords + + + $createDate + $creator + + + $author + $title + $subject + + + 3 + B + + + $invoiceRdf + + + +'''); + } +} diff --git a/pdf/lib/src/pdf/obj/pdfa/sRGB2014.icc b/pdf/lib/src/pdf/obj/pdfa/sRGB2014.icc new file mode 100644 index 0000000000000000000000000000000000000000..49afbfef10f22a1832590b68369d2f248ea553b9 GIT binary patch literal 3024 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 literal 0 HcmV?d00001