Compare commits

..

4 Commits

Author SHA1 Message Date
7eb7197a1c fix: unifies cursor positioning through input manager
removed: block level preserve history removed until accurate reporting of render heights is available
fix: fixes block multiline rendering
2025-06-09 12:54:52 -06:00
04d5044c43 feat: esc to cancel select menus
feat: fieldVisibility tool
2025-06-06 12:54:44 -06:00
7c19ada88b feat: delete fields tool
fix: field rename
fix: list fields now scrolls
2025-06-06 10:50:27 -06:00
7a3b3f2161 reverting fieldRename to last working version 2025-06-04 11:19:21 -06:00
11 changed files with 625 additions and 671 deletions

View File

@ -6,12 +6,13 @@ interface EventMap {
exit: Event;
enter: Event;
backspace: Event;
escape: Event;
delete: Event;
"arrow-left": Event;
"arrow-right": Event;
"arrow-up": Event;
"arrow-down": Event;
[key: string]: Event;
// [key: string]: Event;
}
interface EventDetailMap {
@ -71,6 +72,14 @@ export class CLIKeypressEvent extends CustomEvent<EventDetailMap["keypress"]> {
}
}
type bounds = {
top?: number;
left?: number;
right?: number;
bottom?: number;
boundMode?: "relative" | "absolute";
};
export class InputManager extends ManagerEventTarget {
private static instance = new InputManager();
private active = false;
@ -143,6 +152,13 @@ export class InputManager extends ManagerEventTarget {
if (byte === 127 || byte === 8) {
this.dispatchEvent(new Event("backspace"));
i++;
this.moveCursor(-1, 0);
continue;
}
if (byte === 27 && i + 1 >= n) {
this.dispatchEvent(new Event("escape"));
i++;
continue;
}
@ -150,18 +166,23 @@ export class InputManager extends ManagerEventTarget {
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2];
switch (code) {
case 65:
case 65: // Up
this.moveCursor(0, -1);
this.dispatchEvent(new Event("arrow-up"));
break;
case 66:
case 66: // Down
this.moveCursor(0, 1);
this.dispatchEvent(new Event("arrow-down"));
break;
case 67:
case 67: // Right
this.moveCursor(1, 0);
this.dispatchEvent(new Event("arrow-right"));
break;
case 68:
case 68: // Left
this.moveCursor(-1, 0);
this.dispatchEvent(new Event("arrow-left"));
break;
case 51:
if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete"));
@ -179,6 +200,7 @@ export class InputManager extends ManagerEventTarget {
this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
);
this.moveCursor(1, 0);
i++;
continue;
}
@ -193,4 +215,109 @@ export class InputManager extends ManagerEventTarget {
if (raw) await Deno.stdin.setRaw(false);
}
dispatchKey(key: string) {
switch (key) {
case "enter":
case "backspace":
case "arrow-up":
case "arrow-down":
case "arrow-right":
case "arrow-left":
case "delete":
case "escape":
this.dispatchEvent(new Event(key));
break;
default:
this.dispatchEvent(
new CLICharEvent({ key: key.charCodeAt(0), char: key }),
);
}
}
// Cursor management
private cursor = { row: 0, col: 0 };
private bounds: bounds = {};
getCursor() {
return { ...this.cursor };
}
getRelativeCursor() {
const boundStart = (this.bounds.top ?? 0) * Deno.consoleSize().columns +
(this.bounds.left ?? 0);
const cursorPos = (this.cursor.row * Deno.consoleSize().columns) +
this.cursor.col;
return cursorPos - boundStart;
}
setCursor(row: number, col: number) {
const { columns, rows } = Deno.consoleSize();
const { top, bottom, left, right, boundMode } = {
top: 0,
bottom: rows - 1,
left: 0,
right: columns,
boundMode: "relative",
...this.bounds,
} as bounds;
switch (boundMode) {
case "absolute":
this.cursor.row = Math.max(
top ?? -Infinity,
Math.min(bottom ?? Infinity, row),
);
this.cursor.col = Math.max(
left ?? -Infinity,
Math.min(right ?? Infinity, col),
);
break;
case "relative": {
const boundStart = (top! * columns) + left!;
const boundEnd = (bottom! * columns) + right!;
let proposedPosition = (row * columns) + col;
if (proposedPosition < boundStart) proposedPosition = boundStart;
if (proposedPosition > boundEnd) proposedPosition = boundEnd;
col = proposedPosition % columns;
row = (proposedPosition - col) / columns;
this.cursor.row = row;
this.cursor.col = col;
}
}
this.applyCursor();
}
moveCursor(dx: number, dy: number) {
this.setCursor(this.cursor.row + dy, this.cursor.col + dx);
}
setBounds(
bounds: { top?: number; left?: number; right?: number; bottom?: number },
) {
this.bounds = bounds;
this.setCursor(this.cursor.row, this.cursor.col); // enforce bounds immediately
}
getBounds() {
return { ...this.bounds };
}
updateBounds(
bounds: { top?: number; left?: number; right?: number; bottom?: number },
) {
this.bounds = { ...this.bounds, ...bounds };
this.setCursor(this.cursor.row, this.cursor.col);
}
resetBounds() {
this.bounds = {};
this.setCursor(this.cursor.row, this.cursor.col);
}
private applyCursor() {
Deno.stdout.writeSync(
new TextEncoder().encode(
`\x1b[${this.cursor.row + 1};${this.cursor.col + 1}H`,
),
);
}
}

View File

@ -1,4 +1,5 @@
import { Cursor } from "./cursor.ts";
import { InputManager } from "./InputManager.ts";
export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
@ -132,12 +133,8 @@ export class TerminalBlock {
this.layout = layout;
}
setLines(lines: string[], range?: [number, number]) {
if (range && this.preserveHistory) {
this.lines.splice(range[0], range[1], ...lines);
} else {
this.lines = this.preserveHistory ? this.lines.concat(lines) : lines;
}
setLines(lines: string[]) {
this.lines = lines;
if (this.scrollOffset > lines.length - 1) {
this.scrollOffset = Math.max(0, lines.length - 1);
}
@ -149,11 +146,47 @@ export class TerminalBlock {
);
this.renderInternal();
}
range = [
range?.[0] ?? this.lines.length - lines.length,
range ? range[0] + lines.length : this.lines.length,
];
return range;
}
wrapLines(maxWidth: number): string[] {
const wrapped: string[] = [];
const inputManager = InputManager.getInstance();
const cursor = inputManager.getCursor();
const bounds = inputManager.getBounds();
const blockStart = this.lastRenderRow;
let visualRow = blockStart;
let maxCursorRow = cursor.row;
for (const line of this.lines) {
const chunks: string[] = [];
for (let start = 0; start < line.length; start += maxWidth) {
const chunk = line.slice(start, start + maxWidth);
chunks.push(chunk);
wrapped.push(this.prepend + chunk);
}
const visualLines = chunks.length;
const visualEnd = visualRow + visualLines - 1;
// Check if the cursor is within this wrapped lines visual range
if (cursor.row >= visualRow && cursor.row <= visualEnd) {
maxCursorRow = visualEnd; // this becomes the new bottom bound
}
visualRow = visualEnd + 1;
}
if (maxCursorRow !== cursor.row) {
inputManager.setBounds({
...bounds,
bottom: maxCursorRow - blockStart,
});
}
return wrapped;
}
append(lines: string[]) {
@ -186,7 +219,16 @@ export class TerminalBlock {
}
getRenderedLines(maxHeight: number): string[] {
return this.lines.slice(this.scrollOffset, this.scrollOffset + maxHeight);
const width = Deno.consoleSize().columns - this.prepend.length;
const wrapped = this.wrapLines(width);
return wrapped.slice(this.scrollOffset, this.scrollOffset + maxHeight);
}
getStartRow(): number {
return this.lastRenderRow;
}
getEndRow(): number {
return this.lastRenderRow + this.renderedLineCount - 1;
}
setRenderLines(lines: string[]) {
@ -262,12 +304,21 @@ export class TerminalBlock {
getFixedHeight(): number {
return this.fixedHeight ?? 0;
}
requestCursorAt(
lineOffsetFromStart = 0,
col = 0,
): [row: number, col: number] {
return [this.lastRenderRow + lineOffsetFromStart, col];
}
private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action;
}
runPostRenderAction() {
const im = InputManager.getInstance();
im.moveCursor(0, 0);
if (this._postRenderAction) {
this._postRenderAction.call(this);
this._postRenderAction = undefined;

View File

@ -1,7 +1,7 @@
import { cliPrompt } from "./prompts.ts";
import type { TerminalBlock } from "./TerminalLayout.ts";
type prompt = [string, (v?: string) => boolean] | string;
type prompt = [string, (v?: string) => boolean | undefined] | string;
export async function forceArgs(
args: string[],

View File

@ -13,6 +13,8 @@ const toolRegistry: [string, Promise<{ default: ITool }>][] = [
["checkCode", import("../tools/checkCode.ts")],
["fieldRename", import("../tools/fieldRename.ts")],
["listFormFields", import("../tools/listFormFields.ts")],
["deleteFields", import("../tools/deleteFields.ts")],
["fieldVisibility", import("../tools/fieldVisibility.ts")],
];
export class PdfToolsCli {

View File

@ -4,115 +4,6 @@ import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
// export async function cliPrompt(
// message: string,
// block?: TerminalBlock,
// ): Promise<string> {
// const encoder = new TextEncoder();
// const input: string[] = [];
// let cursorPos = 0;
// await Deno.stdin.setRaw(true);
// const cursorVisible = Cursor["visible"];
// Cursor.show();
// let range: [number, number] = [0, 1];
// if (block) {
// range = block.setLines([message + " "]);
// } else {
// Deno.stdout.writeSync(encoder.encode(message + " "));
// }
// const render = () => {
// const line = message + " " + input.join("");
// const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
// if (block) {
// block.setPostRenderAction(function () {
// Deno.stdout.writeSync(
// encoder.encode(`\x1b[${this["lastRenderRow"]};1H`),
// );
// Deno.stdout.writeSync(encoder.encode(moveTo));
// });
// range = block.setLines([line], range);
// } else {
// Deno.stdout.writeSync(encoder.encode("\x1b[K" + line + moveTo));
// }
// };
// render();
// const buf = new Uint8Array(64); // large enough for most pastes
// inputLoop:
// while (true) {
// const n = await Deno.stdin.read(buf);
// if (n === null) break;
// for (let i = 0; i < n; i++) {
// const byte = buf[i];
// // Ctrl+C
// if (byte === 3) {
// block?.clear();
// block?.["layout"]?.clearAll();
// await Deno.stdin.setRaw(false);
// Deno.exit(130);
// }
// if (byte === 13) { // Enter
// break inputLoop;
// }
// // Escape sequence?
// if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
// const third = buf[i + 2];
// if (third === 68 && cursorPos > 0) cursorPos--; // Left
// else if (third === 67 && cursorPos < input.length) cursorPos++; // Right
// else if (third === 51 && i + 3 < n && buf[i + 3] === 126) { // Delete
// if (cursorPos < input.length) input.splice(cursorPos, 1);
// i += 1; // consume tilde
// }
// i += 2; // consume ESC [ X
// continue;
// }
// // Backspace
// if (byte === 127 || byte === 8) {
// if (cursorPos > 0) {
// input.splice(cursorPos - 1, 1);
// cursorPos--;
// }
// continue;
// }
// // Delete (ASCII 46)
// if (byte === 46 && cursorPos < input.length) {
// input.splice(cursorPos, 1);
// continue;
// }
// // Printable
// if (byte >= 32 && byte <= 126) {
// input.splice(cursorPos, 0, String.fromCharCode(byte));
// cursorPos++;
// }
// // Other cases: ignore
// }
// render();
// }
// await Deno.stdin.setRaw(false);
// if (!cursorVisible) {
// Cursor.hide();
// }
// Deno.stdout.writeSync(encoder.encode("\n"));
// return input.join("");
// }
export async function cliPrompt(
message: string,
block?: TerminalBlock,
@ -120,7 +11,6 @@ export async function cliPrompt(
const encoder = new TextEncoder();
const input: string[] = [];
let cursorPos = 0;
let range: [number, number] | undefined;
Cursor.show();
@ -132,15 +22,21 @@ export async function cliPrompt(
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) {
block.setPostRenderAction(() => {
Deno.stdout.writeSync(encoder.encode(moveTo));
});
range = block.setLines([line], range);
block.setLines([line]);
} else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo));
}
};
render();
const cPos = block?.requestCursorAt(0, message.length + 1);
if (cPos) {
const [row, column] = cPos;
im.setCursor(row, column);
im.setBounds({ top: row, left: column, right: column, bottom: row });
}
const exit = () => {
im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace);
@ -189,13 +85,12 @@ export async function cliPrompt(
const onKey = (e: Event) => {
const ke = (e as CLICharEvent).detail;
cursorPos = im.getRelativeCursor();
input.splice(cursorPos, 0, ke.char);
cursorPos++;
im.updateBounds({ right: input.length + message.length + 1 });
render();
};
render();
return await new Promise<string>((res) => {
resolve = res;
im.addEventListener("enter", onEnter);

View File

@ -30,7 +30,6 @@ export async function selectMenuInteractive(
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
@ -52,7 +51,7 @@ export async function selectMenuInteractive(
}
}
range = terminalBlock.setLines(lines, range);
terminalBlock.setLines(lines);
}
function numberAndPadding(i: number, prefix?: string) {
@ -91,7 +90,17 @@ export async function selectMenuInteractive(
inputBuffer = inputBuffer.slice(0, -1);
};
let resolve: null | ((value: string) => void) = null;
let resolve: null | ((value: string | null) => void) = null;
const onEscape = () => {
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(null);
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
@ -107,33 +116,38 @@ export async function selectMenuInteractive(
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(options[selected]);
};
renderMenu();
await new Promise<string>((res) => {
const final = await new Promise<string | null>((res) => {
resolve = res;
im.addEventListener("char", onKey);
im.addEventListener("backspace", onBackspace);
im.addEventListener("enter", onEnter);
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("escape", onEscape);
});
terminalBlock.setLines(["Selected: " + options[selected]], range);
// terminalBlock.setLines(["Selected: " + final], range);
return options[selected];
return final;
}
export async function multiSelectMenuInteractive(
q: string,
options: string[] | [string, callback][],
config?: ISelectMenuConfig,
options: (string | [string, callback])[],
config?: ISelectMenuConfig & { allOption?: boolean },
): Promise<string[] | null> {
Deno.stdin.setRaw(true);
let selected = 0;
let selectedOptions: number[] = config?.initialSelections || [];
if (config?.allOption) {
options.unshift("Select All");
}
const rawValues = options.map((i) => typeof i === "string" ? i : i[0]);
if (rawValues.length !== options.length) {
@ -145,7 +159,19 @@ export async function multiSelectMenuInteractive(
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
let range: [number, number] = [terminalBlock.lineCount, 1];
const checkSelectAll = () => {
if (selectedOptions.includes(0)) {
selectedOptions = [];
} else {
selectedOptions = Array.from(options).map((_, i) => i);
}
};
const validateSelectAll = () => {
const allPresent = selectedOptions.length == options.length;
if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0);
};
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
@ -170,13 +196,13 @@ export async function multiSelectMenuInteractive(
}
}
range = terminalBlock.setLines(lines, range);
terminalBlock.setLines(lines);
}
const im = InputManager.getInstance();
im.activate();
let resolve = null as null | ((value: number[]) => void);
let resolve = null as null | ((value: number[] | null) => void);
const onUp = (e: Event) => {
e.stopImmediatePropagation();
@ -193,32 +219,47 @@ export async function multiSelectMenuInteractive(
const onSpace = (e: CLICharEvent) => {
if (e.detail.char !== " ") return;
e.stopImmediatePropagation();
if (selectedOptions.includes(selected)) {
if (config?.allOption && selected === 0) {
checkSelectAll();
} else if (selectedOptions.includes(selected)) {
selectedOptions = selectedOptions.filter((i) => i !== selected);
} else {
selectedOptions.push(selected);
}
validateSelectAll();
renderMenu();
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
resolve?.(selectedOptions);
const onEscape = () => {
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(null);
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(selectedOptions);
};
renderMenu();
const selections = await new Promise<number[]>((res) => {
const selections = await new Promise<number[] | null>((res) => {
resolve = res;
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter);
im.addEventListener("escape", onEscape);
});
if (!selections) return null;
for (const optionI of selections) {
const option = options[optionI];
if (Array.isArray(option)) {
@ -226,7 +267,7 @@ export async function multiSelectMenuInteractive(
}
}
const final = selectedOptions.map((i) => rawValues[i]);
terminalBlock.setLines(["Selected: " + final.join(", ")], range);
terminalBlock.setLines(["Selected: " + final.join(", ")]);
return final;
}
@ -299,8 +340,8 @@ if (import.meta.main) {
"ximenia",
"yuzu",
"zucchini",
], { terminalBlock: block });
cliLog(val || "No value");
], { terminalBlock: block, allOption: true });
cliLog(val || "No value", block);
// Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
}

View File

@ -1,9 +1,9 @@
{
"name": "@bearmetal/pdf-tools",
"version": "1.0.8-l",
"version": "1.0.8-p",
"license": "GPL 3.0",
"tasks": {
"dev": "deno run -A --env-file=.env main.ts",
"dev": "deno run -A main.ts",
"compile": "deno compile -o pdf-tools.exe --target x86_64-pc-windows-msvc --include ./asciiart.txt -A ./main.ts",
"install": "deno install -fgq --import-map ./deno.json -n checkfields -R ./main.ts",
"debug": "deno run -A --env-file=.env --inspect-wait --watch main.ts"
@ -32,4 +32,4 @@
"./must_await_cli_prompts.ts"
]
}
}
}

49
tools/deleteFields.ts Normal file
View File

@ -0,0 +1,49 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { cliPrompt } from "../cli/prompts.ts";
import { multiSelectMenuInteractive } from "../cli/selectMenu.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import type { callback, ITool } from "../types.ts";
import { loadPdf, savePdf } from "util/saveLoadPdf.ts";
export class DeleteFormFields implements ITool {
name = "deleteFormFields";
description = "delete multiple form fields from a PDF";
block?: TerminalBlock;
async run(pdfPath: string = "") {
if (!this.block) this.block = new TerminalBlock();
[pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF",
(d) => d?.endsWith(".pdf"),
]], this.block);
const pdf = await loadPdf(pdfPath);
const form = pdf.getForm();
const fields = form.getFields();
let updatesMade = false;
await multiSelectMenuInteractive(
`${pdfPath}\nSelect fields to delete:`,
fields.map<[string, callback]>((
f,
) => [f.getName(), () => {
while (f.acroField.getWidgets().length) {
f.acroField.removeWidget(0);
}
form.removeField(f);
updatesMade = true;
}]),
);
if (!updatesMade) return;
const path = await cliPrompt(
"Save to path (or hit enter to keep current):",
this.block,
) || pdfPath;
await savePdf(pdf, path);
}
help?: (() => Promise<void> | void) | undefined;
done?: (() => Promise<void> | void) | undefined;
setBlock(block: TerminalBlock) {
this.block = block;
}
}
export default new DeleteFormFields();

View File

@ -1,18 +1,13 @@
import {
PDFAcroField,
PDFAcroTerminal,
type PDFAcroField,
PDFArray,
PDFCheckBox,
PDFContext,
PDFDict,
type PDFDocument,
type PDFField,
PDFHexString,
PDFName,
PDFNumber,
type PDFObject,
PDFRadioGroup,
PDFRef,
type PDFRef,
PDFString,
PDFTextField,
type PDFWidgetAnnotation,
@ -25,458 +20,63 @@ import { cliAlert, cliLog, cliPrompt } from "../cli/prompts.ts";
import { multiSelectMenuInteractive } from "../cli/selectMenu.ts";
import type { callback, ITool } from "../types.ts";
import { toCase } from "util/caseManagement.ts";
import { log } from "util/logfile.ts";
function removeWidgetFromOldField(
doc: PDFDocument,
function applyRename(
field: PDFField,
widget: PDFWidgetAnnotation,
name: string,
pattern: RegExp,
change: string,
) {
const maybeKids = field.acroField.dict.get(PDFName.of("Kids"));
if (!maybeKids || !(maybeKids instanceof PDFArray)) return;
const kids = maybeKids;
if (!kids) return;
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) return;
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 page = findPageForWidget(doc, widget);
if (!page) throw new Error("Widget's page not found");
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");
}
const sourceFieldDict = field.acroField.dict;
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;
const segments = name.split(".");
const matchingSegments = segments.filter((s) => pattern.test(s));
let cField: PDFAcroField | undefined = field.acroField;
while (cField) {
if (
cField.getPartialName() &&
matchingSegments.includes(cField.getPartialName()!)
) {
const mName = cField.getPartialName()?.replace(pattern, change);
if (mName) {
cField.dict.set(PDFName.of("T"), PDFString.of(mName));
// console.log(cField.getPartialName())
}
}
case "/Ch": {
const ff = sourceFieldDict.get(PDFName.of("Ff"));
const isCombo = ff instanceof PDFNumber &&
((ff.asNumber() & (1 << 17)) !== 0);
const opts = sourceFieldDict.lookupMaybe(PDFName.of("Opt"), PDFArray);
const values =
opts?.asArray().map((opt) =>
opt instanceof PDFString || opt instanceof PDFHexString
? opt.decodeText()
: ""
) ?? [];
if (isCombo) {
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:
throw new Error(`Unsupported field type: ${fieldType}`);
}
// 🔧 Apply styles *after creation*
const targetWidgetDict = newField.acroField.getWidgets()[0].dict;
copyFieldAndWidgetStyles(
sourceFieldDict,
sourceWidgetDict,
newField.acroField.dict,
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");
}
cField = cField.getParent();
// console.log(cField?.getPartialName())
}
}
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;
// function applyWidgetRename(
// doc: PDFDocument,
// field: PDFField,
// widget: PDFWidgetAnnotation,
// name: string,
// pattern: RegExp,
// change: string,
// ) {
// if (field.acroField.getWidgets().length > 1) {
// const widgets = field.acroField.getWidgets();
// const widgetIndex = widgets.indexOf(widget);
// widgets.splice(widgetIndex, 1);
const remainingFields = fields.asArray().filter((ref) => {
const dict = context.lookup(ref, PDFDict);
const name = dict?.get(PDFName.of("T"));
// const pdfDocContext = doc.context;
if (name && (name.decodeText?.() === fieldName)) {
context.delete(ref as PDFRef);
return false;
}
// const originalRef = field.acroField.ref;
// const originalFieldDict = pdfDocContext.lookup(originalRef);
// if (!originalFieldDict) return;
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 newFieldDict = pdfDocContext.obj({
// ...originalFieldDict,
// T: PDFString.of(name.replace(pattern, change)),
// Kids: [getWidgetRef(widget, doc.getPages())],
// });
// const newField = pdfDocContext.register(newFieldDict);
// const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
// const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
// fields.push(newField);
// }
// }
function findPageForWidget(
doc: PDFDocument,
widget: PDFWidgetAnnotation,
@ -534,22 +134,19 @@ function applyWidgetRename(
try {
const form = doc.getForm();
const widgets = field.acroField.getWidgets();
if (widgets.length <= 1) return;
const widgetDict = widget.dict;
const widgetIndex = widgets.findIndex((w) => w.dict === widgetDict);
if (widgetIndex === -1) return;
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) return;
// Remove widget from internal widgets list
widgets.splice(widgetIndex, 1);
// Remove from /Kids
const maybeKids = field.acroField.dict.get(PDFName.of("Kids"));
if (maybeKids instanceof PDFArray) {
const updatedKids = maybeKids.asArray().filter((ref) => {
const kids = field.acroField.dict.lookup(PDFName.of("Kids"), PDFArray);
if (kids) {
const updatedKids = kids.asArray().filter((ref) => {
const maybeDict = doc.context.lookup(ref);
return maybeDict !== widgetDict;
return maybeDict !== widget.dict;
});
field.acroField.dict.set(
PDFName.of("Kids"),
@ -558,41 +155,48 @@ function applyWidgetRename(
}
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();
if (!rect) throw new Error("Widget has no rectangle");
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;
try {
targetField = form.getField(finalName);
} catch {
//
log("Failed to find existing field");
// Field doesn't exist — that's fine
}
// Compare field types if field exists
if (targetField) {
const sourceType = detectFieldType(field);
const targetType = detectFieldType(targetField);
if (sourceType !== targetType) {
throw new Error(
`Field "${finalName}" already exists with a different type (${targetType} vs ${sourceType})`,
);
}
// Add widget to existing field
widget.dict.set(PDFName.of("Parent"), targetField.acroField.ref);
const kids = targetField.acroField.dict.lookup(
// ✅ Same type — attach widget to the existing field
// const targetFieldWidgets = targetField.acroField.getWidgets();
const targetKidsArray = targetField.acroField.dict.lookup(
PDFName.of("Kids"),
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 {
targetField.acroField.dict.set(
PDFName.of("Kids"),
@ -600,23 +204,22 @@ function applyWidgetRename(
);
}
const annots = page.node.Annots()?.asArray() ?? [];
if (!annots.includes(widgetRef)) {
annots.push(widgetRef);
page.node.set(PDFName.of("Annots"), doc.context.obj(annots));
// Also ensure widget is attached to a page
const page = findPageForWidget(doc, widget);
if (!page) throw new Error("Widget's page not found");
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);
removeWidgetCompletely(doc, widget, field);
removeFieldIfEmpty(doc, field);
return;
return; // Done
}
// No existing field — create new one and move widget
removeWidgetFromPage(widget, doc);
removeWidgetCompletely(doc, widget, field);
removeFieldIfEmpty(doc, field);
const fieldType = detectFieldType(field);
let newField: PDFField;
@ -627,12 +230,6 @@ function applyWidgetRename(
const val = field.getText();
if (val) tf.setText(val);
}
tf.addToPage(page, {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
newField = tf;
break;
}
@ -640,8 +237,8 @@ function applyWidgetRename(
case "/Btn": {
const isRadio = getFlag(field, 15);
if (isRadio) {
const radio = form.createRadioGroup(finalName);
radio.addOptionToPage(finalName, page, {
const rf = form.createRadioGroup(finalName);
rf.addOptionToPage(finalName, page, {
x: rect.x,
y: rect.y,
width: rect.width,
@ -649,7 +246,7 @@ function applyWidgetRename(
});
if (field instanceof PDFRadioGroup) {
const selected = field.getSelected();
if (selected) radio.select(selected);
if (selected) rf.select(selected);
}
return;
} else {
@ -671,15 +268,20 @@ function applyWidgetRename(
throw new Error(`Unsupported field type: ${fieldType}`);
}
// Apply styles from old field/widget after creation
copyFieldAndWidgetStyles(
field.acroField.dict,
widget.dict,
newField.acroField.dict,
newField.acroField.getWidgets()[0].dict,
);
} catch (e) {
log("applyWidgetRename error:", e);
// Attach the new field to the page if necessary
if (
newField instanceof PDFTextField ||
newField instanceof PDFCheckBox
) {
newField.addToPage(page, {
x: rect.x,
y: rect.y,
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
*
@ -772,7 +404,7 @@ class RenameFields implements ITool {
if (!this.block) {
this.block = new TerminalBlock();
}
this.block.setPreserveHistory(true);
this.block.setPreserveHistory(false);
[pdfPath, pattern, change] = await forceArgs(
[pdfPath, pattern, change],
@ -869,12 +501,7 @@ class RenameFields implements ITool {
new RegExp(patternRegex),
toChange,
)
: moveWidgetToFlatField(
pdf,
field,
field.acroField.getWidgets()[0],
preview,
);
: applyRename(field, name, patternRegex, toChange);
changesMade = true;
},
];
@ -900,7 +527,7 @@ class RenameFields implements ITool {
try {
await savePdf(pdf, path || pdfPath);
} catch {
log(e);
// log(e);
}
} else {
cliLog("No changes made, skipping", this.block);

135
tools/fieldVisibility.ts Normal file
View File

@ -0,0 +1,135 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { selectMenuInteractive } from "../cli/selectMenu.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import type { ITool } from "../types.ts";
import { loadPdf, savePdf } from "util/saveLoadPdf.ts";
import { PDFName, type PDFNumber, type PDFWidgetAnnotation } from "pdf-lib";
import { cliPrompt } from "../cli/prompts.ts";
type AcrobatVisibility =
| "Visible"
| "Hidden"
| "VisibleButDoesNotPrint"
| "HiddenButPrintable";
export function getAcrobatVisibility(
widget: PDFWidgetAnnotation,
): AcrobatVisibility {
const raw = widget.dict.lookup(PDFName.of("F")) as PDFNumber;
const flags = raw?.asNumber?.() ?? 0;
const isInvisible = (flags & (1 << 1)) !== 0;
const isHidden = (flags & (1 << 2)) !== 0;
const isNoPrint = (flags & (1 << 3)) !== 0;
const isPrint = (flags & (1 << 2)) === 0 && !isNoPrint;
if (isInvisible && isHidden) {
return isPrint ? "HiddenButPrintable" : "Hidden";
} else if (isNoPrint) {
return "VisibleButDoesNotPrint";
} else {
return "Visible";
}
}
export function setAcrobatVisibility(
widget: PDFWidgetAnnotation,
visibility: AcrobatVisibility,
) {
let flags = 0;
switch (visibility) {
case "Visible":
// No visibility bits set
break;
case "Hidden":
flags |= 1 << 1; // Invisible
flags |= 1 << 2; // Hidden
break;
case "VisibleButDoesNotPrint":
flags |= 1 << 3; // NoPrint
break;
case "HiddenButPrintable":
flags |= 1 << 1; // Invisible
flags |= 1 << 2; // Hidden
flags |= 1 << 3; // NoPrint — UNset this to allow print
break;
}
widget.dict.set(PDFName.of("F"), widget.dict.context.obj(flags));
}
export class FieldVisibility implements ITool {
name = "Field Visibility";
description = "Change visibility of fields";
block?: TerminalBlock;
async run(pdfPath: string) {
if (!this.block) this.block = new TerminalBlock();
this.block.setPreserveHistory(false);
[pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF:",
(e) => e?.endsWith(".pdf"),
]], this.block);
const pdf = await loadPdf(pdfPath);
const form = pdf.getForm();
const fields = form.getFields();
let changesMade = false;
while (true) {
const fieldAndVisibility = await selectMenuInteractive(
`Select a field to change visibility (ESC to ${
changesMade ? "continue" : "cancel"
})`,
fields.flatMap((f) => {
const name = f.getName();
const visibility = f.acroField.getWidgets().map((w, i, a) =>
`${name}${a.length > 1 ? "#" + i : ""} :: ${
getAcrobatVisibility(w)
}`
);
return visibility;
}),
{ terminalBlock: this.block },
);
if (!fieldAndVisibility) break;
const visibility = await selectMenuInteractive(
fieldAndVisibility,
[
"Visible",
"Hidden",
"HiddenButPrintable",
"VisibleButDoesNotPrint",
] as AcrobatVisibility[],
{ terminalBlock: this.block },
) as AcrobatVisibility | null;
if (!visibility) continue;
const [fName, widgetIndex] = fieldAndVisibility.split("::")[0].trim()
.split("#");
const field = fields.find((f) => f.getName() === fName);
if (!field) break;
const widget = field.acroField.getWidgets()[Number(widgetIndex) || 0];
setAcrobatVisibility(widget, visibility);
changesMade = true;
}
if (changesMade) {
const path = await cliPrompt(
"Save to path (or hit enter to keep current):",
this.block,
) || pdfPath;
savePdf(pdf, path);
}
}
help?: (() => Promise<void> | void) | undefined;
done?: (() => Promise<void> | void) | undefined;
setBlock(block: TerminalBlock) {
this.block = block;
}
}
export default new FieldVisibility();

View File

@ -1,8 +1,8 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { cliAlert } from "../cli/prompts.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import { loadPdfForm } from "util/saveLoadPdf.ts";
import type { ITool } from "../types.ts";
import { InputManager } from "../cli/InputManager.ts";
export class ListFormFields implements ITool {
name = "listformfields";
@ -12,21 +12,48 @@ export class ListFormFields implements ITool {
if (!this.block) {
this.block = new TerminalBlock();
}
this.block.setPreserveHistory(true);
this.block.setPreserveHistory(false);
[pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF:",
(p) => !!p && p.endsWith(".pdf"),
]], this.block);
const lines = [pdfPath];
let rLines: string[] = [];
const form = await loadPdfForm(pdfPath);
const fields = form.getFields();
const fieldNames = fields.map((f) => f.getName());
const lines = [];
for (const fieldName of fieldNames) {
lines.push(fieldName);
}
this.block.setLines(lines, [0, 1]);
await cliAlert("", this.block);
let offset = 0;
const buildRLines = () => {
rLines = fieldNames.slice(offset, this.block!.getRenderHeight());
this.block!.setLines(lines.concat(rLines));
};
buildRLines();
await new Promise<void>((res) => {
const im = InputManager.getInstance();
const up = () => {
if (fieldNames.length < this.block!.getRenderHeight() - 1) return;
offset = Math.max(0, offset - 1);
buildRLines();
};
const down = () => {
if (fieldNames.length < this.block!.getRenderHeight() - 1) return;
offset = Math.min(fieldNames.length, offset + 1);
buildRLines();
};
const enter = () => {
res();
im.removeEventListener("arrow-up", up);
im.removeEventListener("arrow-down", down);
im.removeEventListener("enter", enter);
};
im.addEventListener("arrow-up", up);
im.addEventListener("arrow-down", down);
im.addEventListener("enter", enter);
});
}
setBlock(terminalBlock: TerminalBlock) {
this.block = terminalBlock;