142 lines
3.9 KiB
TypeScript
142 lines
3.9 KiB
TypeScript
import {
|
|
decodePDFRawStream,
|
|
PDFDict,
|
|
PDFDocument,
|
|
PDFHexString,
|
|
PDFName,
|
|
PDFRawStream,
|
|
PDFString,
|
|
} from "pdf-lib"
|
|
|
|
export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note"
|
|
|
|
export type ElectronicInvoiceDetection = {
|
|
container: "pdf" | "xml"
|
|
syntax: ElectronicInvoiceSyntax
|
|
xml: Buffer
|
|
attachmentName: string | null
|
|
}
|
|
|
|
type FileLike = {
|
|
name?: string | null
|
|
path?: string | null
|
|
mimeType?: string | null
|
|
}
|
|
|
|
const decodePdfText = (value: unknown): string | null => {
|
|
if (value instanceof PDFString || value instanceof PDFHexString) {
|
|
return value.decodeText()
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
|
|
const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8")
|
|
|
|
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) {
|
|
return "cii"
|
|
}
|
|
|
|
if (
|
|
/<(?:[A-Za-z_][\w.-]*:)?Invoice(?:\s|>)/i.test(sample)
|
|
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
|
|
) {
|
|
return "ubl-invoice"
|
|
}
|
|
|
|
if (
|
|
/<(?:[A-Za-z_][\w.-]*:)?CreditNote(?:\s|>)/i.test(sample)
|
|
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
|
|
) {
|
|
return "ubl-credit-note"
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => {
|
|
const context = fileSpec.context
|
|
const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict)
|
|
|
|
if (!embeddedFiles) return null
|
|
|
|
const stream = context.lookup(
|
|
embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")),
|
|
)
|
|
|
|
if (!(stream instanceof PDFRawStream)) return null
|
|
|
|
const name = decodePdfText(
|
|
context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))),
|
|
)
|
|
const contents = Buffer.from(decodePDFRawStream(stream).decode())
|
|
|
|
return { name, xml: contents }
|
|
}
|
|
|
|
const detectEmbeddedInvoice = async (pdf: Buffer): Promise<ElectronicInvoiceDetection | null> => {
|
|
let document: PDFDocument
|
|
|
|
try {
|
|
document = await PDFDocument.load(pdf, {
|
|
ignoreEncryption: true,
|
|
updateMetadata: false,
|
|
})
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
for (const [, object] of document.context.enumerateIndirectObjects()) {
|
|
if (!(object instanceof PDFDict)) continue
|
|
|
|
const type = object.get(PDFName.of("Type"))
|
|
if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue
|
|
|
|
const embedded = extractXmlFromFileSpec(object)
|
|
if (!embedded) continue
|
|
|
|
const syntax = detectXmlSyntax(embedded.xml)
|
|
if (!syntax) continue
|
|
|
|
return {
|
|
container: "pdf",
|
|
syntax,
|
|
xml: embedded.xml,
|
|
attachmentName: embedded.name,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const hasPdfFileType = (file: FileLike, data: Buffer) => {
|
|
const filename = String(file.name || file.path || "").toLowerCase()
|
|
const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim()
|
|
|
|
return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-"
|
|
}
|
|
|
|
export const detectElectronicInvoice = async (
|
|
data: Buffer,
|
|
file: FileLike,
|
|
): Promise<ElectronicInvoiceDetection | null> => {
|
|
if (hasPdfFileType(file, data)) {
|
|
return detectEmbeddedInvoice(data)
|
|
}
|
|
|
|
const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<")
|
|
const syntax = beginsLikeXml ? detectXmlSyntax(data) : null
|
|
|
|
if (syntax) {
|
|
return {
|
|
container: "xml",
|
|
syntax,
|
|
xml: data,
|
|
attachmentName: file.name || file.path?.split("/").pop() || null,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|