reverting fieldRename to last working version

This commit is contained in:
Emmaline Autumn 2025-06-04 11:19:21 -06:00
parent 65743d8562
commit 7a3b3f2161

View File

@ -1,18 +1,13 @@
import { import {
PDFAcroField, type PDFAcroField,
PDFAcroTerminal,
PDFArray, PDFArray,
PDFCheckBox, PDFCheckBox,
PDFContext,
PDFDict,
type PDFDocument, type PDFDocument,
type PDFField, type PDFField,
PDFHexString,
PDFName, PDFName,
PDFNumber, PDFNumber,
type PDFObject,
PDFRadioGroup, PDFRadioGroup,
PDFRef, type PDFRef,
PDFString, PDFString,
PDFTextField, PDFTextField,
type PDFWidgetAnnotation, type PDFWidgetAnnotation,
@ -25,458 +20,63 @@ import { cliAlert, cliLog, cliPrompt } from "../cli/prompts.ts";
import { multiSelectMenuInteractive } from "../cli/selectMenu.ts"; import { multiSelectMenuInteractive } from "../cli/selectMenu.ts";
import type { callback, ITool } from "../types.ts"; import type { callback, ITool } from "../types.ts";
import { toCase } from "util/caseManagement.ts"; import { toCase } from "util/caseManagement.ts";
import { log } from "util/logfile.ts";
function removeWidgetFromOldField( function applyRename(
doc: PDFDocument,
field: PDFField, field: PDFField,
widget: PDFWidgetAnnotation, name: string,
pattern: RegExp,
change: string,
) { ) {
const maybeKids = field.acroField.dict.get(PDFName.of("Kids")); const segments = name.split(".");
if (!maybeKids || !(maybeKids instanceof PDFArray)) return; const matchingSegments = segments.filter((s) => pattern.test(s));
const kids = maybeKids; let cField: PDFAcroField | undefined = field.acroField;
if (!kids) return; while (cField) {
if (
const widgetRef = getWidgetRef(widget, doc); cField.getPartialName() &&
if (!widgetRef) return; matchingSegments.includes(cField.getPartialName()!)
const updatedKids = kids.asArray().filter((ref) => {
const dict = doc.context.lookup(ref);
return dict !== widget.dict;
});
if (updatedKids.length === 0) {
// Field is now empty, remove it from the AcroForm
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
const fieldRef = field.acroField.ref;
const newFields = fields.asArray().filter((ref) => ref !== fieldRef);
acroForm.set(PDFName.of("Fields"), doc.context.obj(newFields));
} else {
field.acroField.dict.set(PDFName.of("Kids"), doc.context.obj(updatedKids));
}
}
function moveWidgetToFlatField(
doc: PDFDocument,
field: PDFField,
widget: PDFWidgetAnnotation,
newName: string,
) { ) {
const form = doc.getForm(); const mName = cField.getPartialName()?.replace(pattern, change);
const page = findPageForWidget(doc, widget); if (mName) {
if (!page) throw new Error("Widget's page not found"); cField.dict.set(PDFName.of("T"), PDFString.of(mName));
// console.log(cField.getPartialName())
const rect = widget.getRectangle();
if (!rect) throw new Error("Widget has no rectangle");
const fieldType = detectFieldType(field);
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) throw new Error("Widget ref not found");
// 🔒 Extract value + style before any destructive ops
let value: string | undefined;
try {
if (fieldType === "/Tx" && field instanceof PDFTextField) {
value = field.getText();
} }
} catch (_) {
log("Failed to extract value from field");
} }
cField = cField.getParent();
const sourceFieldDict = field.acroField.dict; // console.log(cField?.getPartialName())
const sourceWidgetDict = widget.dict;
// 🔥 Remove widget from page + field
removeWidgetFromPage(widget, doc);
removeWidgetCompletely(doc, widget, field);
// 🔥 Carefully remove field + parents
try {
fullyDeleteFieldHierarchy(doc, field);
} catch (_) {
// fallback
log("Failed to remove field hierarchy");
removeFieldIfEmpty(doc, field);
}
sanitizeFieldsTree(doc);
removeDanglingParents(doc);
removeEmptyAncestors(doc, field);
// 🔁 Create replacement field
let newField: PDFField;
switch (fieldType) {
case "/Tx": {
const tf = form.createTextField(newName);
if (value) tf.setText(value);
tf.addToPage(page, rect);
newField = tf;
break;
}
case "/Btn": {
const isRadio = getFlag(field, 15);
if (isRadio) {
const rg = form.createRadioGroup(newName);
rg.addOptionToPage(newName, page, rect);
return;
} else {
const cb = form.createCheckBox(newName);
cb.addToPage(page, rect);
if (field instanceof PDFCheckBox && field.isChecked()) {
cb.check();
}
return;
} }
} }
case "/Ch": { // function applyWidgetRename(
const ff = sourceFieldDict.get(PDFName.of("Ff")); // doc: PDFDocument,
const isCombo = ff instanceof PDFNumber && // field: PDFField,
((ff.asNumber() & (1 << 17)) !== 0); // widget: PDFWidgetAnnotation,
const opts = sourceFieldDict.lookupMaybe(PDFName.of("Opt"), PDFArray); // name: string,
const values = // pattern: RegExp,
opts?.asArray().map((opt) => // change: string,
opt instanceof PDFString || opt instanceof PDFHexString // ) {
? opt.decodeText() // if (field.acroField.getWidgets().length > 1) {
: "" // const widgets = field.acroField.getWidgets();
) ?? []; // const widgetIndex = widgets.indexOf(widget);
// widgets.splice(widgetIndex, 1);
if (isCombo) { // const pdfDocContext = doc.context;
const dd = form.createDropdown(newName);
dd.addOptions(values);
dd.addToPage(page, rect);
newField = dd;
} else {
const ol = form.createOptionList(newName);
ol.addOptions(values);
ol.addToPage(page, rect);
newField = ol;
}
break;
}
default: // const originalRef = field.acroField.ref;
throw new Error(`Unsupported field type: ${fieldType}`); // const originalFieldDict = pdfDocContext.lookup(originalRef);
} // if (!originalFieldDict) return;
// 🔧 Apply styles *after creation* // const newFieldDict = pdfDocContext.obj({
const targetWidgetDict = newField.acroField.getWidgets()[0].dict; // ...originalFieldDict,
copyFieldAndWidgetStyles( // T: PDFString.of(name.replace(pattern, change)),
sourceFieldDict, // Kids: [getWidgetRef(widget, doc.getPages())],
sourceWidgetDict, // });
newField.acroField.dict, // const newField = pdfDocContext.register(newFieldDict);
targetWidgetDict,
);
}
function removeDanglingParents(doc: PDFDocument) {
const context = doc.context;
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fields = acroForm.lookupMaybe(PDFName.of("Fields"), PDFArray);
if (!(fields instanceof PDFArray)) return;
function fixFieldDict(dict: PDFDict) {
const parentRef = dict.get(PDFName.of("Parent"));
if (!parentRef || !(parentRef instanceof PDFRef)) return;
try {
const parentDict = context.lookup(parentRef, PDFDict);
if (!parentDict) throw new Error("Missing parent");
} catch {
// Parent is broken — remove reference
dict.delete(PDFName.of("Parent"));
log("Broken parent reference removed");
}
}
const visited = new Set<string>();
function recurseKids(dict: PDFDict) {
const kids = dict.lookupMaybe(PDFName.of("Kids"), PDFArray);
if (!(kids instanceof PDFArray)) return;
for (const kidRef of kids.asArray()) {
if (!(kidRef instanceof PDFRef)) continue;
const key = kidRef.toString();
if (visited.has(key)) continue;
visited.add(key);
try {
const kidDict = context.lookup(kidRef, PDFDict);
fixFieldDict(kidDict);
recurseKids(kidDict);
} catch (e) {
context.delete(kidRef); // nuke broken reference
log("Broken kid reference removed");
log(e);
}
}
}
for (const ref of fields.asArray()) {
if (!(ref instanceof PDFRef)) continue;
try {
const dict = context.lookup(ref, PDFDict);
fixFieldDict(dict);
recurseKids(dict);
} catch {
context.delete(ref); // broken root
log("Broken root reference removed");
}
}
}
function removeFieldByName(doc: PDFDocument, fieldName: string) {
const form = doc.getForm();
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
const context = doc.context;
const remainingFields = fields.asArray().filter((ref) => {
const dict = context.lookup(ref, PDFDict);
const name = dict?.get(PDFName.of("T"));
if (name && (name.decodeText?.() === fieldName)) {
context.delete(ref as PDFRef);
return false;
}
return true;
});
acroForm.set(PDFName.of("Fields"), context.obj(remainingFields));
}
function sanitizeFieldsTree(doc: PDFDocument) {
const context = doc.context;
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fields = acroForm.lookupMaybe(PDFName.of("Fields"), PDFArray);
if (!(fields instanceof PDFArray)) return;
function pruneInvalidKids(dict: PDFDict, context: PDFContext) {
const kids = dict.lookupMaybe(PDFName.of("Kids"), PDFArray);
if (!(kids instanceof PDFArray)) return;
const validKids: PDFRef[] = [];
for (const ref of kids.asArray()) {
// 💥 Defensive: skip anything that's not a real PDFRef
if (!ref || !(ref instanceof PDFRef)) continue;
let child: PDFDict | undefined;
try {
child = context.lookup(ref, PDFDict);
} catch (e) {
context.delete(ref);
log("Broken kid reference removed");
log(e);
continue;
}
if (!child) {
context.delete(ref);
continue;
}
const t = child.get(PDFName.of("T"));
if (!(t instanceof PDFString || t instanceof PDFHexString)) {
context.delete(ref);
continue;
}
// Recurse, but protect inner layers too
pruneInvalidKids(child, context);
validKids.push(ref);
}
if (validKids.length > 0) {
dict.set(PDFName.of("Kids"), context.obj(validKids));
} else {
dict.delete(PDFName.of("Kids"));
}
}
const validFields: PDFRef[] = [];
for (const ref of fields.asArray()) {
if (!ref || !(ref instanceof PDFRef)) continue;
let dict: PDFDict | undefined;
try {
dict = context.lookup(ref, PDFDict);
} catch {
context.delete(ref);
log("Broken field reference removed");
continue;
}
if (!dict) {
context.delete(ref);
continue;
}
const t = dict.get(PDFName.of("T"));
if (!(t instanceof PDFString || t instanceof PDFHexString)) {
context.delete(ref);
continue;
}
pruneInvalidKids(dict, context);
validFields.push(ref);
}
acroForm.set(PDFName.of("Fields"), context.obj(validFields));
}
function fullyDeleteFieldHierarchy(doc: PDFDocument, rootField: PDFField) {
const context = doc.context;
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
function recurseDelete(dict: PDFDict, ref: PDFRef) {
const kids = dict.lookupMaybe(PDFName.of("Kids"), PDFArray);
if (kids instanceof PDFArray) {
for (const kidRef of kids.asArray()) {
const kidDict = context.lookup(kidRef, PDFDict);
if (kidDict) {
recurseDelete(kidDict, kidRef as PDFRef);
}
}
}
context.delete(ref);
}
recurseDelete(rootField.acroField.dict, rootField.acroField.ref);
// Remove root from AcroForm.Fields
const newFields = fields
.asArray()
.filter((ref) => ref !== rootField.acroField.ref);
acroForm.set(PDFName.of("Fields"), context.obj(newFields));
}
function removeEmptyAncestors(doc: PDFDocument, field: PDFField) {
let current: PDFAcroField | undefined = field.acroField;
const context = doc.context;
while (current) {
const parent = current.getParent();
const kids = parent?.dict.lookupMaybe(PDFName.of("Kids"), PDFArray);
if (kids instanceof PDFArray) {
const remaining = kids.asArray().filter((ref) => {
try {
const kidDict = context.lookup(ref, PDFDict);
return kidDict !== current?.dict;
} catch (e) {
log("Broken kid reference removed");
log(e);
return false;
}
});
if (remaining.length > 0) {
parent.dict.set(PDFName.of("Kids"), context.obj(remaining));
break;
} else {
parent.dict.delete(PDFName.of("Kids"));
}
}
context.delete(current.ref);
current = parent;
}
}
function removeWidgetCompletely(
doc: PDFDocument,
widget: PDFWidgetAnnotation,
field: PDFField,
) {
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) return;
// 1. Remove from field's /Kids array
const kidsRaw = field.acroField.dict.get(PDFName.of("Kids"));
if (kidsRaw instanceof PDFArray) {
const updatedKids = kidsRaw.asArray().filter((ref) => {
const dict = doc.context.lookup(ref);
return dict !== widget.dict;
});
if (updatedKids.length > 0) {
field.acroField.dict.set(
PDFName.of("Kids"),
doc.context.obj(updatedKids),
);
} else {
field.acroField.dict.delete(PDFName.of("Kids"));
}
}
// 2. Remove from page /Annots
for (const page of doc.getPages()) {
const annotsRaw = page.node.Annots()?.asArray();
if (!annotsRaw) continue;
const remainingAnnots = annotsRaw.filter((ref) => {
const dict = doc.context.lookup(ref);
return dict !== widget.dict;
});
page.node.set(PDFName.of("Annots"), doc.context.obj(remainingAnnots));
}
// Optional: delete the widget from the context
doc.context.delete(widgetRef);
}
function removeFieldIfEmpty(doc: PDFDocument, field: PDFField) {
const kids = field.acroField.getWidgets();
if (kids.length > 0) return;
const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
const fieldsArray = acroForm.lookup(PDFName.of("Fields"), PDFArray);
const ref = field.acroField.ref;
const updatedFields = fieldsArray.asArray().filter((f) => f !== ref);
acroForm.set(PDFName.of("Fields"), doc.context.obj(updatedFields));
// Optional: remove field object entirely
doc.context.delete(ref);
}
function copyFieldAndWidgetStyles(
sourceFieldDict: PDFDict,
sourceWidgetDict: PDFDict,
targetFieldDict: PDFDict,
targetWidgetDict: PDFDict,
) {
const fieldKeys = ["DA", "DR", "Q"];
const widgetKeys = ["MK", "BS", "Border"];
// Copy from field dict → field dict
for (const key of fieldKeys) {
const val = sourceFieldDict.get(PDFName.of(key));
if (val) {
targetFieldDict.set(PDFName.of(key), val);
}
}
// Copy from widget dict → widget dict
for (const key of widgetKeys) {
const val = sourceWidgetDict.get(PDFName.of(key));
if (val) {
targetWidgetDict.set(PDFName.of(key), val);
}
}
}
// const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
// const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
// fields.push(newField);
// }
// }
function findPageForWidget( function findPageForWidget(
doc: PDFDocument, doc: PDFDocument,
widget: PDFWidgetAnnotation, widget: PDFWidgetAnnotation,
@ -534,22 +134,19 @@ function applyWidgetRename(
try { try {
const form = doc.getForm(); const form = doc.getForm();
const widgets = field.acroField.getWidgets(); const widgets = field.acroField.getWidgets();
if (widgets.length <= 1) return;
const widgetDict = widget.dict; const widgetDict = widget.dict;
const widgetIndex = widgets.findIndex((w) => w.dict === widgetDict); const widgetIndex = widgets.findIndex((w) => w.dict === widgetDict);
if (widgetIndex === -1) return; if (widgetIndex === -1) return;
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) return;
// Remove widget from internal widgets list
widgets.splice(widgetIndex, 1); widgets.splice(widgetIndex, 1);
// Remove from /Kids const kids = field.acroField.dict.lookup(PDFName.of("Kids"), PDFArray);
const maybeKids = field.acroField.dict.get(PDFName.of("Kids")); if (kids) {
if (maybeKids instanceof PDFArray) { const updatedKids = kids.asArray().filter((ref) => {
const updatedKids = maybeKids.asArray().filter((ref) => {
const maybeDict = doc.context.lookup(ref); const maybeDict = doc.context.lookup(ref);
return maybeDict !== widgetDict; return maybeDict !== widget.dict;
}); });
field.acroField.dict.set( field.acroField.dict.set(
PDFName.of("Kids"), PDFName.of("Kids"),
@ -558,41 +155,48 @@ function applyWidgetRename(
} }
const page = findPageForWidget(doc, widget); const page = findPageForWidget(doc, widget);
if (!page) throw new Error("Widget's page not found"); if (!page) throw new Error("Widget page not found");
const rect = widget.getRectangle(); const rect = widget.getRectangle();
if (!rect) throw new Error("Widget has no rectangle"); if (!rect) throw new Error("Widget has no rectangle");
const finalName = newName.replace(pattern, change); const finalName = newName.replace(pattern, change);
const fieldType = detectFieldType(field);
// Attempt to find an existing field with the new name // Try to get existing field with the new name
let targetField: PDFField | undefined; let targetField: PDFField | undefined;
try { try {
targetField = form.getField(finalName); targetField = form.getField(finalName);
} catch { } catch {
// // Field doesn't exist — that's fine
log("Failed to find existing field");
} }
// Compare field types if field exists
if (targetField) { if (targetField) {
const sourceType = detectFieldType(field); const sourceType = detectFieldType(field);
const targetType = detectFieldType(targetField); const targetType = detectFieldType(targetField);
if (sourceType !== targetType) { if (sourceType !== targetType) {
throw new Error( throw new Error(
`Field "${finalName}" already exists with a different type (${targetType} vs ${sourceType})`, `Field "${finalName}" already exists with a different type (${targetType} vs ${sourceType})`,
); );
} }
// Add widget to existing field // ✅ Same type — attach widget to the existing field
widget.dict.set(PDFName.of("Parent"), targetField.acroField.ref); // const targetFieldWidgets = targetField.acroField.getWidgets();
const targetKidsArray = targetField.acroField.dict.lookup(
const kids = targetField.acroField.dict.lookup(
PDFName.of("Kids"), PDFName.of("Kids"),
PDFArray, PDFArray,
); );
if (kids) {
kids.push(widgetRef); // Set /Parent on the widget to point to the existing field
widget.dict.set(PDFName.of("Parent"), targetField.acroField.ref);
// Add the widget to the field's /Kids array
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) throw new Error("Widget ref not found");
if (targetKidsArray) {
targetKidsArray.push(widgetRef);
} else { } else {
targetField.acroField.dict.set( targetField.acroField.dict.set(
PDFName.of("Kids"), PDFName.of("Kids"),
@ -600,23 +204,22 @@ function applyWidgetRename(
); );
} }
const annots = page.node.Annots()?.asArray() ?? []; // Also ensure widget is attached to a page
if (!annots.includes(widgetRef)) { const page = findPageForWidget(doc, widget);
annots.push(widgetRef); if (!page) throw new Error("Widget's page not found");
page.node.set(PDFName.of("Annots"), doc.context.obj(annots));
const pageAnnots = page.node.Annots();
const refs = pageAnnots?.asArray() ?? [];
if (!refs.includes(widgetRef)) {
refs.push(widgetRef);
page.node.set(PDFName.of("Annots"), doc.context.obj(refs));
} }
removeWidgetFromPage(widget, doc); return; // Done
removeWidgetCompletely(doc, widget, field);
removeFieldIfEmpty(doc, field);
return;
} }
// No existing field — create new one and move widget
removeWidgetFromPage(widget, doc); removeWidgetFromPage(widget, doc);
removeWidgetCompletely(doc, widget, field);
removeFieldIfEmpty(doc, field); const fieldType = detectFieldType(field);
let newField: PDFField; let newField: PDFField;
@ -627,12 +230,6 @@ function applyWidgetRename(
const val = field.getText(); const val = field.getText();
if (val) tf.setText(val); if (val) tf.setText(val);
} }
tf.addToPage(page, {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
newField = tf; newField = tf;
break; break;
} }
@ -640,8 +237,8 @@ function applyWidgetRename(
case "/Btn": { case "/Btn": {
const isRadio = getFlag(field, 15); const isRadio = getFlag(field, 15);
if (isRadio) { if (isRadio) {
const radio = form.createRadioGroup(finalName); const rf = form.createRadioGroup(finalName);
radio.addOptionToPage(finalName, page, { rf.addOptionToPage(finalName, page, {
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
@ -649,7 +246,7 @@ function applyWidgetRename(
}); });
if (field instanceof PDFRadioGroup) { if (field instanceof PDFRadioGroup) {
const selected = field.getSelected(); const selected = field.getSelected();
if (selected) radio.select(selected); if (selected) rf.select(selected);
} }
return; return;
} else { } else {
@ -671,15 +268,20 @@ function applyWidgetRename(
throw new Error(`Unsupported field type: ${fieldType}`); throw new Error(`Unsupported field type: ${fieldType}`);
} }
// Apply styles from old field/widget after creation // Attach the new field to the page if necessary
copyFieldAndWidgetStyles( if (
field.acroField.dict, newField instanceof PDFTextField ||
widget.dict, newField instanceof PDFCheckBox
newField.acroField.dict, ) {
newField.acroField.getWidgets()[0].dict, newField.addToPage(page, {
); x: rect.x,
} catch (e) { y: rect.y,
log("applyWidgetRename error:", e); width: rect.width,
height: rect.height,
});
}
} catch {
// log(e);
} }
} }
@ -702,6 +304,36 @@ function removeWidgetFromPage(widget: PDFWidgetAnnotation, doc: PDFDocument) {
} }
} }
// function getWidgetRef(
// widget: PDFWidgetAnnotation,
// pages: PDFPage[],
// ): PDFRef | undefined {
// const widgetRect = (widget?.dict?.get(PDFName.of("Rect")) as PDFArray)
// ?.asArray();
// const widgetFT = (widget?.dict?.get(PDFName.of("FT")) as PDFString)
// ?.["value"];
// for (const page of pages) {
// const annotsArray = page.node.Annots()?.asArray();
// if (!annotsArray) continue;
// for (const annotRef of annotsArray) {
// const annotDict = page.doc.context.lookup(annotRef);
// if (!annotDict) continue;
// if (!(annotDict instanceof PDFDict)) continue;
// const rect = (annotDict.get(PDFName.of("Rect")) as PDFArray)?.asArray();
// const ft = (annotDict.get(PDFName.of("FT")) as PDFString)?.["value"];
// // rudimentary match (you can add more checks like /T, /Subtype, etc.)
// if (rect?.toString() === widgetRect?.toString() && ft === widgetFT) {
// return annotRef as PDFRef;
// }
// }
// }
// return undefined;
// }
/*** /***
* Evaluates the change string with the match array * Evaluates the change string with the match array
* *
@ -869,12 +501,7 @@ class RenameFields implements ITool {
new RegExp(patternRegex), new RegExp(patternRegex),
toChange, toChange,
) )
: moveWidgetToFlatField( : applyRename(field, name, patternRegex, toChange);
pdf,
field,
field.acroField.getWidgets()[0],
preview,
);
changesMade = true; changesMade = true;
}, },
]; ];
@ -900,7 +527,7 @@ class RenameFields implements ITool {
try { try {
await savePdf(pdf, path || pdfPath); await savePdf(pdf, path || pdfPath);
} catch { } catch {
log(e); // log(e);
} }
} else { } else {
cliLog("No changes made, skipping", this.block); cliLog("No changes made, skipping", this.block);