588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||
import * as plugins from '../plugins.js';
|
||
import { EInvoice } from '../../../ts/index.js';
|
||
import { CorpusLoader } from '../../helpers/corpus.loader.js';
|
||
import { PerformanceTracker as StaticPerformanceTracker } from '../performance.tracker.js';
|
||
import { rgb } from 'pdf-lib';
|
||
|
||
// Simple instance-based performance tracker for this test
|
||
class SimplePerformanceTracker {
|
||
private measurements: Map<string, number[]> = new Map();
|
||
private name: string;
|
||
|
||
constructor(name: string) {
|
||
this.name = name;
|
||
}
|
||
|
||
addMeasurement(key: string, time: number): void {
|
||
if (!this.measurements.has(key)) {
|
||
this.measurements.set(key, []);
|
||
}
|
||
this.measurements.get(key)!.push(time);
|
||
}
|
||
|
||
getAverageTime(): number {
|
||
let total = 0;
|
||
let count = 0;
|
||
for (const times of this.measurements.values()) {
|
||
for (const time of times) {
|
||
total += time;
|
||
count++;
|
||
}
|
||
}
|
||
return count > 0 ? total / count : 0;
|
||
}
|
||
|
||
printSummary(): void {
|
||
console.log(`\n${this.name} - Performance Summary:`);
|
||
for (const [key, times] of this.measurements) {
|
||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||
const min = Math.min(...times);
|
||
const max = Math.max(...times);
|
||
console.log(` ${key}: avg=${avg.toFixed(2)}ms, min=${min.toFixed(2)}ms, max=${max.toFixed(2)}ms (${times.length} runs)`);
|
||
}
|
||
console.log(` Overall average: ${this.getAverageTime().toFixed(2)}ms`);
|
||
}
|
||
}
|
||
|
||
const performanceTracker = new SimplePerformanceTracker('PDF-11: PDF/A Compliance');
|
||
|
||
tap.test('PDF-11: PDF/A-3 Creation and Validation', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
// PDF/A-3 allows embedded files (required for ZUGFeRD/Factur-X)
|
||
// Set PDF/A identification
|
||
pdfDoc.setTitle('PDF/A-3 Compliant Invoice');
|
||
pdfDoc.setAuthor('EInvoice System');
|
||
pdfDoc.setSubject('Electronic Invoice with embedded XML');
|
||
pdfDoc.setKeywords(['PDF/A-3', 'ZUGFeRD', 'Factur-X', 'invoice']);
|
||
pdfDoc.setCreator('EInvoice PDF/A Generator');
|
||
pdfDoc.setProducer('PDFLib with PDF/A-3 compliance');
|
||
|
||
// Add required metadata for PDF/A
|
||
const creationDate = new Date('2025-01-25T10:00:00Z');
|
||
const modDate = new Date('2025-01-25T10:00:00Z');
|
||
pdfDoc.setCreationDate(creationDate);
|
||
pdfDoc.setModificationDate(modDate);
|
||
|
||
// Create page with required elements for PDF/A
|
||
const page = pdfDoc.addPage([595, 842]); // A4
|
||
|
||
// Use embedded fonts (required for PDF/A)
|
||
const helveticaFont = await pdfDoc.embedFont('Helvetica');
|
||
|
||
// Add content
|
||
page.drawText('PDF/A-3 Compliant Invoice', {
|
||
x: 50,
|
||
y: 750,
|
||
size: 20,
|
||
font: helveticaFont
|
||
});
|
||
|
||
page.drawText('Invoice Number: INV-2025-001', {
|
||
x: 50,
|
||
y: 700,
|
||
size: 12,
|
||
font: helveticaFont
|
||
});
|
||
|
||
page.drawText('This document complies with PDF/A-3 standard', {
|
||
x: 50,
|
||
y: 650,
|
||
size: 10,
|
||
font: helveticaFont
|
||
});
|
||
|
||
// Add required OutputIntent for PDF/A
|
||
// Note: pdf-lib doesn't directly support OutputIntent
|
||
// In production, a specialized library would be needed
|
||
|
||
// Embed invoice XML (allowed in PDF/A-3)
|
||
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||
<rsm:ExchangedDocument>
|
||
<ram:ID xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">INV-2025-001</ram:ID>
|
||
<ram:TypeCode xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">380</ram:TypeCode>
|
||
<ram:IssueDateTime xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
|
||
<udt:DateTimeString xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" format="102">20250125</udt:DateTimeString>
|
||
</ram:IssueDateTime>
|
||
</rsm:ExchangedDocument>
|
||
</rsm:CrossIndustryInvoice>`;
|
||
|
||
await pdfDoc.attach(
|
||
Buffer.from(xmlContent, 'utf8'),
|
||
'invoice.xml',
|
||
{
|
||
mimeType: 'application/xml',
|
||
description: 'ZUGFeRD invoice data',
|
||
afRelationship: plugins.AFRelationship.Data,
|
||
creationDate: creationDate,
|
||
modificationDate: modDate
|
||
}
|
||
);
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
|
||
// Verify basic structure
|
||
expect(pdfBytes.length).toBeGreaterThan(0);
|
||
console.log('Created PDF/A-3 structure (full compliance requires specialized tools)');
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('pdfa3-creation', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: PDF/A-1b compliance check', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
// PDF/A-1b: Basic compliance (visual appearance preservation)
|
||
pdfDoc.setTitle('PDF/A-1b Test Document');
|
||
pdfDoc.setCreationDate(new Date());
|
||
|
||
const page = pdfDoc.addPage();
|
||
|
||
// PDF/A-1b requirements:
|
||
// - All fonts must be embedded
|
||
// - No transparency
|
||
// - No JavaScript
|
||
// - No audio/video
|
||
// - No encryption
|
||
// - Proper color space definition
|
||
|
||
const helveticaFont = await pdfDoc.embedFont('Helvetica');
|
||
|
||
page.drawText('PDF/A-1b Compliant Document', {
|
||
x: 50,
|
||
y: 750,
|
||
size: 16,
|
||
font: helveticaFont,
|
||
color: rgb(0, 0, 0) // RGB color space
|
||
});
|
||
|
||
// Add text without transparency
|
||
page.drawText('No transparency allowed in PDF/A-1b', {
|
||
x: 50,
|
||
y: 700,
|
||
size: 12,
|
||
font: helveticaFont,
|
||
color: rgb(0, 0, 0),
|
||
opacity: 1.0 // Full opacity required
|
||
});
|
||
|
||
// Draw rectangle without transparency
|
||
page.drawRectangle({
|
||
x: 50,
|
||
y: 600,
|
||
width: 200,
|
||
height: 50,
|
||
color: rgb(0.9, 0.9, 0.9),
|
||
borderColor: rgb(0, 0, 0),
|
||
borderWidth: 1,
|
||
opacity: 1.0
|
||
});
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
|
||
// Check for PDF/A-1b violations
|
||
const pdfString = pdfBytes.toString('binary');
|
||
|
||
// Check for prohibited features
|
||
const violations = [];
|
||
if (pdfString.includes('/JS')) violations.push('JavaScript detected');
|
||
if (pdfString.includes('/Launch')) violations.push('External launch action detected');
|
||
if (pdfString.includes('/Sound')) violations.push('Sound annotation detected');
|
||
if (pdfString.includes('/Movie')) violations.push('Movie annotation detected');
|
||
if (pdfString.includes('/Encrypt')) violations.push('Encryption detected');
|
||
|
||
console.log('PDF/A-1b compliance check:');
|
||
if (violations.length === 0) {
|
||
console.log('No obvious violations detected');
|
||
} else {
|
||
console.log('Potential violations:', violations);
|
||
}
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('pdfa1b-compliance', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: PDF/A metadata requirements', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
// Required XMP metadata for PDF/A
|
||
const xmpMetadata = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||
<rdf:Description rdf:about=""
|
||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/"
|
||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
|
||
<dc:title>
|
||
<rdf:Alt>
|
||
<rdf:li xml:lang="x-default">PDF/A Compliant Invoice</rdf:li>
|
||
</rdf:Alt>
|
||
</dc:title>
|
||
<dc:creator>
|
||
<rdf:Seq>
|
||
<rdf:li>EInvoice System</rdf:li>
|
||
</rdf:Seq>
|
||
</dc:creator>
|
||
<dc:description>
|
||
<rdf:Alt>
|
||
<rdf:li xml:lang="x-default">Invoice with PDF/A compliance</rdf:li>
|
||
</rdf:Alt>
|
||
</dc:description>
|
||
<pdfaid:part>3</pdfaid:part>
|
||
<pdfaid:conformance>B</pdfaid:conformance>
|
||
<xmp:CreateDate>2025-01-25T10:00:00Z</xmp:CreateDate>
|
||
<xmp:ModifyDate>2025-01-25T10:00:00Z</xmp:ModifyDate>
|
||
<xmp:MetadataDate>2025-01-25T10:00:00Z</xmp:MetadataDate>
|
||
<pdf:Producer>EInvoice PDF/A Generator</pdf:Producer>
|
||
</rdf:Description>
|
||
</rdf:RDF>
|
||
</x:xmpmeta>
|
||
<?xpacket end="w"?>`;
|
||
|
||
// Set standard metadata
|
||
pdfDoc.setTitle('PDF/A Compliant Invoice');
|
||
pdfDoc.setAuthor('EInvoice System');
|
||
pdfDoc.setSubject('Invoice with PDF/A compliance');
|
||
pdfDoc.setKeywords(['PDF/A', 'invoice', 'compliant']);
|
||
|
||
const page = pdfDoc.addPage();
|
||
page.drawText('Document with PDF/A Metadata', { x: 50, y: 750, size: 16 });
|
||
|
||
// Note: pdf-lib doesn't support direct XMP metadata embedding
|
||
// This would require post-processing or a specialized library
|
||
|
||
console.log('PDF/A metadata structure defined (requires specialized tools for embedding)');
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('pdfa-metadata', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: Color space compliance', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
const page = pdfDoc.addPage();
|
||
|
||
// PDF/A requires proper color space definitions
|
||
// Test different color spaces
|
||
|
||
// Device RGB (most common for screen display)
|
||
page.drawText('Device RGB Color Space', {
|
||
x: 50,
|
||
y: 750,
|
||
size: 14,
|
||
color: rgb(0.8, 0.2, 0.2)
|
||
});
|
||
|
||
// Grayscale
|
||
page.drawText('Device Gray Color Space', {
|
||
x: 50,
|
||
y: 700,
|
||
size: 14,
|
||
color: rgb(0.5, 0.5, 0.5)
|
||
});
|
||
|
||
// Test color accuracy
|
||
const colors = [
|
||
{ name: 'Pure Red', rgb: rgb(1, 0, 0) },
|
||
{ name: 'Pure Green', rgb: rgb(0, 1, 0) },
|
||
{ name: 'Pure Blue', rgb: rgb(0, 0, 1) },
|
||
{ name: 'Black', rgb: rgb(0, 0, 0) },
|
||
{ name: 'White', rgb: rgb(1, 1, 1) }
|
||
];
|
||
|
||
let yPos = 600;
|
||
colors.forEach(color => {
|
||
page.drawRectangle({
|
||
x: 50,
|
||
y: yPos,
|
||
width: 30,
|
||
height: 20,
|
||
color: color.rgb
|
||
});
|
||
|
||
page.drawText(color.name, {
|
||
x: 90,
|
||
y: yPos + 5,
|
||
size: 10,
|
||
color: rgb(0, 0, 0)
|
||
});
|
||
|
||
yPos -= 30;
|
||
});
|
||
|
||
// Add OutputIntent description
|
||
page.drawText('OutputIntent: sRGB IEC61966-2.1', {
|
||
x: 50,
|
||
y: 400,
|
||
size: 10,
|
||
color: rgb(0, 0, 0)
|
||
});
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
console.log('Created PDF with color space definitions for PDF/A');
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('color-space', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: Font embedding compliance', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
// PDF/A requires all fonts to be embedded
|
||
const page = pdfDoc.addPage();
|
||
|
||
// Embed standard fonts
|
||
const helvetica = await pdfDoc.embedFont('Helvetica');
|
||
const helveticaBold = await pdfDoc.embedFont('Helvetica-Bold');
|
||
const helveticaOblique = await pdfDoc.embedFont('Helvetica-Oblique');
|
||
const timesRoman = await pdfDoc.embedFont('Times-Roman');
|
||
const courier = await pdfDoc.embedFont('Courier');
|
||
|
||
// Use embedded fonts
|
||
page.drawText('Helvetica Regular (Embedded)', {
|
||
x: 50,
|
||
y: 750,
|
||
size: 14,
|
||
font: helvetica
|
||
});
|
||
|
||
page.drawText('Helvetica Bold (Embedded)', {
|
||
x: 50,
|
||
y: 720,
|
||
size: 14,
|
||
font: helveticaBold
|
||
});
|
||
|
||
page.drawText('Helvetica Oblique (Embedded)', {
|
||
x: 50,
|
||
y: 690,
|
||
size: 14,
|
||
font: helveticaOblique
|
||
});
|
||
|
||
page.drawText('Times Roman (Embedded)', {
|
||
x: 50,
|
||
y: 660,
|
||
size: 14,
|
||
font: timesRoman
|
||
});
|
||
|
||
page.drawText('Courier (Embedded)', {
|
||
x: 50,
|
||
y: 630,
|
||
size: 14,
|
||
font: courier
|
||
});
|
||
|
||
// Test font subset embedding
|
||
page.drawText('Font Subset Test: €£¥§¶•', {
|
||
x: 50,
|
||
y: 580,
|
||
size: 14,
|
||
font: helvetica
|
||
});
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
|
||
// Check that the PDF was created successfully with fonts
|
||
// pdf-lib handles font embedding internally for standard fonts
|
||
console.log(`PDF size: ${pdfBytes.length} bytes`);
|
||
|
||
// A PDF with text content should be larger than a minimal empty PDF
|
||
expect(pdfBytes.length).toBeGreaterThan(1000);
|
||
|
||
// Also verify the PDF is valid
|
||
expect(pdfBytes[0]).toEqual(0x25); // %
|
||
expect(pdfBytes[1]).toEqual(0x50); // P
|
||
expect(pdfBytes[2]).toEqual(0x44); // D
|
||
expect(pdfBytes[3]).toEqual(0x46); // F
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('font-embedding', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: PDF/A-3 with ZUGFeRD attachment', async () => {
|
||
const startTime = performance.now();
|
||
|
||
const { PDFDocument, AFRelationship } = plugins;
|
||
const pdfDoc = await PDFDocument.create();
|
||
|
||
// Configure for ZUGFeRD/Factur-X compliance
|
||
pdfDoc.setTitle('ZUGFeRD Invoice PDF/A-3');
|
||
pdfDoc.setAuthor('ZUGFeRD Generator');
|
||
pdfDoc.setSubject('Electronic Invoice with embedded XML');
|
||
pdfDoc.setKeywords(['ZUGFeRD', 'PDF/A-3', 'Factur-X', 'electronic invoice']);
|
||
pdfDoc.setCreator('EInvoice ZUGFeRD Module');
|
||
|
||
const page = pdfDoc.addPage();
|
||
const helvetica = await pdfDoc.embedFont('Helvetica');
|
||
|
||
// Invoice header
|
||
page.drawText('RECHNUNG / INVOICE', {
|
||
x: 50,
|
||
y: 750,
|
||
size: 20,
|
||
font: helvetica
|
||
});
|
||
|
||
page.drawText('Rechnungsnummer / Invoice No: 2025-001', {
|
||
x: 50,
|
||
y: 700,
|
||
size: 12,
|
||
font: helvetica
|
||
});
|
||
|
||
page.drawText('Rechnungsdatum / Invoice Date: 25.01.2025', {
|
||
x: 50,
|
||
y: 680,
|
||
size: 12,
|
||
font: helvetica
|
||
});
|
||
|
||
// ZUGFeRD XML attachment
|
||
const zugferdXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||
<rsm:ExchangedDocumentContext>
|
||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||
<ram:ID>urn:cen.eu:en16931:2017#conformant#urn:zugferd.de:2p1:extended</ram:ID>
|
||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||
</rsm:ExchangedDocumentContext>
|
||
<rsm:ExchangedDocument>
|
||
<ram:ID>2025-001</ram:ID>
|
||
<ram:TypeCode>380</ram:TypeCode>
|
||
<ram:IssueDateTime>
|
||
<udt:DateTimeString format="102">20250125</udt:DateTimeString>
|
||
</ram:IssueDateTime>
|
||
</rsm:ExchangedDocument>
|
||
</rsm:CrossIndustryInvoice>`;
|
||
|
||
// Attach with proper relationship for ZUGFeRD
|
||
await pdfDoc.attach(
|
||
Buffer.from(zugferdXml, 'utf8'),
|
||
'zugferd-invoice.xml',
|
||
{
|
||
mimeType: 'application/xml',
|
||
description: 'ZUGFeRD Invoice Data',
|
||
afRelationship: AFRelationship.Data
|
||
}
|
||
);
|
||
|
||
const pdfBytes = await pdfDoc.save();
|
||
|
||
// Test loading
|
||
const einvoice = await EInvoice.fromPdf(pdfBytes);
|
||
|
||
console.log('Created PDF/A-3 compliant ZUGFeRD invoice');
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('zugferd-pdfa3', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: Corpus PDF/A compliance check', async () => {
|
||
const startTime = performance.now();
|
||
let pdfaCount = 0;
|
||
let processedCount = 0;
|
||
const complianceIndicators = {
|
||
'PDF/A identification': 0,
|
||
'Embedded fonts': 0,
|
||
'No encryption': 0,
|
||
'Metadata present': 0,
|
||
'Color space defined': 0
|
||
};
|
||
|
||
// Get PDF files from different categories
|
||
const categories = ['ZUGFERD_V1_CORRECT', 'ZUGFERD_V2_CORRECT', 'ZUGFERD_V2_FAIL', 'UNSTRUCTURED'] as const;
|
||
const allPdfFiles: Array<{ path: string; size: number }> = [];
|
||
|
||
for (const category of categories) {
|
||
try {
|
||
const files = await CorpusLoader.loadCategory(category);
|
||
const pdfFiles = files.filter(f => f.path.toLowerCase().endsWith('.pdf'));
|
||
allPdfFiles.push(...pdfFiles);
|
||
} catch (error) {
|
||
console.log(`Could not load category ${category}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Sample PDFs for PDF/A compliance indicators
|
||
const sampleSize = Math.min(40, allPdfFiles.length);
|
||
const sample = allPdfFiles.slice(0, sampleSize);
|
||
|
||
for (const file of sample) {
|
||
try {
|
||
const content = await CorpusLoader.loadFile(file.path);
|
||
const pdfString = content.toString('binary');
|
||
|
||
// Check for PDF/A indicators
|
||
let isPdfA = false;
|
||
|
||
if (pdfString.includes('pdfaid:part') || pdfString.includes('PDF/A')) {
|
||
isPdfA = true;
|
||
complianceIndicators['PDF/A identification']++;
|
||
}
|
||
|
||
if (pdfString.includes('/Type /Font') && pdfString.includes('/FontFile')) {
|
||
complianceIndicators['Embedded fonts']++;
|
||
}
|
||
|
||
if (!pdfString.includes('/Encrypt')) {
|
||
complianceIndicators['No encryption']++;
|
||
}
|
||
|
||
if (pdfString.includes('/Metadata') || pdfString.includes('xmpmeta')) {
|
||
complianceIndicators['Metadata present']++;
|
||
}
|
||
|
||
if (pdfString.includes('/OutputIntent') || pdfString.includes('/ColorSpace')) {
|
||
complianceIndicators['Color space defined']++;
|
||
}
|
||
|
||
if (isPdfA) {
|
||
pdfaCount++;
|
||
console.log(`Potential PDF/A file: ${file.path}`);
|
||
}
|
||
|
||
processedCount++;
|
||
} catch (error) {
|
||
console.log(`Error checking ${file.path}:`, error.message);
|
||
}
|
||
}
|
||
|
||
console.log(`Corpus PDF/A analysis (${processedCount} PDFs):`);
|
||
console.log(`- Potential PDF/A files: ${pdfaCount}`);
|
||
console.log('Compliance indicators:', complianceIndicators);
|
||
|
||
const elapsed = performance.now() - startTime;
|
||
performanceTracker.addMeasurement('corpus-pdfa', elapsed);
|
||
});
|
||
|
||
tap.test('PDF-11: Performance Summary', async () => {
|
||
// Print performance summary
|
||
performanceTracker.printSummary();
|
||
|
||
// Performance assertions
|
||
const avgTime = performanceTracker.getAverageTime();
|
||
expect(avgTime).toBeLessThan(400); // PDF/A operations may take longer
|
||
});
|
||
|
||
tap.start(); |