1- import { readFileSync , writeFileSync } from "node:fs" ;
1+ import { spawnSync } from "node:child_process" ;
2+ import { mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { tmpdir } from "node:os" ;
4+ import { join } from "node:path" ;
25import type { FieldType , FormField , PDFPage } from "@libpdf/core" ;
36import { PDF , PdfArray , PdfDict , PdfName , PdfString } from "@libpdf/core" ;
47
@@ -62,16 +65,63 @@ export async function extractFields(pdfPath: string): Promise<PdfFieldInfo[]> {
6265 return extractFieldsFromBytes ( readFileSync ( pdfPath ) ) ;
6366}
6467
68+ export async function loadPdf ( bytes : Uint8Array | Buffer ) : Promise < PDF > {
69+ const doc = await PDF . load ( bytes ) ;
70+ if ( ! doc . isEncrypted ) return doc ;
71+ return PDF . load ( decryptWithQpdf ( bytes ) ) ;
72+ }
73+
6574/** Same as extractFields but accepts raw bytes instead of a path. */
6675export async function extractFieldsFromBytes (
6776 bytes : Uint8Array | Buffer ,
6877) : Promise < PdfFieldInfo [ ] > {
69- const doc = await PDF . load ( bytes ) ;
78+ const doc = await loadPdf ( bytes ) ;
7079 const form = doc . getForm ( ) ;
7180 const sorted = fieldReadingOrder ( form ?. getFields ( ) ?? [ ] , doc . getPages ( ) ) ;
7281 return sorted . map ( ( f ) => ( { name : f . name , type : f . type } ) ) ;
7382}
7483
84+ /**
85+ * Temporary workaround for https://github.com/LibPDF-js/core/issues/82.
86+ *
87+ * When a PDF is encrypted with an empty user password but an unknown owner
88+ * password, libpdf authenticates successfully (isAuthenticated: true) but
89+ * removeProtection() throws PermissionDeniedError. Calling save() without
90+ * removing protection regenerates the /ID trailer entry, invalidating the AES
91+ * key derivation — the saved file is unreadable. qpdf derives the correct file
92+ * key from the empty user password and strips encryption without needing the
93+ * owner password. Remove this function and inline PDF.load() in loadPdf() once
94+ * libpdf preserves the original /ID on save().
95+ */
96+ function decryptWithQpdf ( bytes : Uint8Array | Buffer ) : Buffer {
97+ const tmpDir = mkdtempSync ( join ( tmpdir ( ) , "namesake-pdf-" ) ) ;
98+ try {
99+ const inPath = join ( tmpDir , "input.pdf" ) ;
100+ const outPath = join ( tmpDir , "output.pdf" ) ;
101+ writeFileSync ( inPath , bytes ) ;
102+ const result = spawnSync ( "qpdf" , [ "--decrypt" , inPath , outPath ] , {
103+ timeout : 60_000 ,
104+ } ) ;
105+ if ( result . error ) {
106+ const isNotFound =
107+ ( result . error as NodeJS . ErrnoException ) . code === "ENOENT" ;
108+ throw new Error (
109+ isNotFound
110+ ? "qpdf is required to process encrypted PDFs. Install it with: brew install qpdf"
111+ : `qpdf failed: ${ result . error . message } ` ,
112+ ) ;
113+ }
114+ if ( result . status !== 0 ) {
115+ throw new Error (
116+ "PDF is password-protected. Please provide an unprotected version." ,
117+ ) ;
118+ }
119+ return readFileSync ( outPath ) ;
120+ } finally {
121+ rmSync ( tmpDir , { recursive : true , force : true } ) ;
122+ }
123+ }
124+
75125/**
76126 * Converts all dropdown fields to plain text fields in the PDF on disk.
77127 * This lets fillPdf write any value without needing to match the PDF's fixed
@@ -81,7 +131,7 @@ export async function convertDropdownsToTextFields(
81131 pdfPath : string ,
82132) : Promise < void > {
83133 const bytes = readFileSync ( pdfPath ) ;
84- const doc = await PDF . load ( bytes ) ;
134+ const doc = await loadPdf ( bytes ) ;
85135 const form = doc . getForm ( ) ;
86136 let changed = false ;
87137 for ( const field of form ?. getFields ( ) ?? [ ] ) {
@@ -105,7 +155,7 @@ export async function normalizeRadioOptionNames(
105155 pdfPath : string ,
106156) : Promise < void > {
107157 const bytes = readFileSync ( pdfPath ) ;
108- const doc = await PDF . load ( bytes ) ;
158+ const doc = await loadPdf ( bytes ) ;
109159 const form = doc . getForm ( ) ;
110160 let changed = false ;
111161
@@ -176,7 +226,7 @@ export async function applyRenames(
176226 renames : Rename [ ] ,
177227) : Promise < void > {
178228 const bytes = readFileSync ( pdfPath ) ;
179- const doc = await PDF . load ( bytes ) ;
229+ const doc = await loadPdf ( bytes ) ;
180230 const form = doc . getForm ( ) ;
181231 const acroForm = form ?. acroForm ( ) ;
182232 for ( const { from, to } of renames ) {
0 commit comments