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; exit: Event;
enter: Event; enter: Event;
backspace: Event; backspace: Event;
escape: Event;
delete: Event; delete: Event;
"arrow-left": Event; "arrow-left": Event;
"arrow-right": Event; "arrow-right": Event;
"arrow-up": Event; "arrow-up": Event;
"arrow-down": Event; "arrow-down": Event;
[key: string]: Event; // [key: string]: Event;
} }
interface EventDetailMap { 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 { export class InputManager extends ManagerEventTarget {
private static instance = new InputManager(); private static instance = new InputManager();
private active = false; private active = false;
@ -143,6 +152,13 @@ export class InputManager extends ManagerEventTarget {
if (byte === 127 || byte === 8) { if (byte === 127 || byte === 8) {
this.dispatchEvent(new Event("backspace")); this.dispatchEvent(new Event("backspace"));
i++; i++;
this.moveCursor(-1, 0);
continue;
}
if (byte === 27 && i + 1 >= n) {
this.dispatchEvent(new Event("escape"));
i++;
continue; continue;
} }
@ -150,18 +166,23 @@ export class InputManager extends ManagerEventTarget {
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2]; const code = buf[i + 2];
switch (code) { switch (code) {
case 65: case 65: // Up
this.moveCursor(0, -1);
this.dispatchEvent(new Event("arrow-up")); this.dispatchEvent(new Event("arrow-up"));
break; break;
case 66: case 66: // Down
this.moveCursor(0, 1);
this.dispatchEvent(new Event("arrow-down")); this.dispatchEvent(new Event("arrow-down"));
break; break;
case 67: case 67: // Right
this.moveCursor(1, 0);
this.dispatchEvent(new Event("arrow-right")); this.dispatchEvent(new Event("arrow-right"));
break; break;
case 68: case 68: // Left
this.moveCursor(-1, 0);
this.dispatchEvent(new Event("arrow-left")); this.dispatchEvent(new Event("arrow-left"));
break; break;
case 51: case 51:
if (i + 3 < n && buf[i + 3] === 126) { if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete")); this.dispatchEvent(new Event("delete"));
@ -179,6 +200,7 @@ export class InputManager extends ManagerEventTarget {
this.dispatchEvent( this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }), new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
); );
this.moveCursor(1, 0);
i++; i++;
continue; continue;
} }
@ -193,4 +215,109 @@ export class InputManager extends ManagerEventTarget {
if (raw) await Deno.stdin.setRaw(false); 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 { Cursor } from "./cursor.ts";
import { InputManager } from "./InputManager.ts";
export class TerminalLayout { export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h"; private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
@ -132,12 +133,8 @@ export class TerminalBlock {
this.layout = layout; this.layout = layout;
} }
setLines(lines: string[], range?: [number, number]) { setLines(lines: string[]) {
if (range && this.preserveHistory) { this.lines = lines;
this.lines.splice(range[0], range[1], ...lines);
} else {
this.lines = this.preserveHistory ? this.lines.concat(lines) : lines;
}
if (this.scrollOffset > lines.length - 1) { if (this.scrollOffset > lines.length - 1) {
this.scrollOffset = Math.max(0, lines.length - 1); this.scrollOffset = Math.max(0, lines.length - 1);
} }
@ -149,11 +146,47 @@ export class TerminalBlock {
); );
this.renderInternal(); this.renderInternal();
} }
range = [ }
range?.[0] ?? this.lines.length - lines.length,
range ? range[0] + lines.length : this.lines.length, wrapLines(maxWidth: number): string[] {
]; const wrapped: string[] = [];
return range; 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[]) { append(lines: string[]) {
@ -186,7 +219,16 @@ export class TerminalBlock {
} }
getRenderedLines(maxHeight: number): string[] { 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[]) { setRenderLines(lines: string[]) {
@ -262,12 +304,21 @@ export class TerminalBlock {
getFixedHeight(): number { getFixedHeight(): number {
return this.fixedHeight ?? 0; return this.fixedHeight ?? 0;
} }
requestCursorAt(
lineOffsetFromStart = 0,
col = 0,
): [row: number, col: number] {
return [this.lastRenderRow + lineOffsetFromStart, col];
}
private _postRenderAction?: () => void; private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) { setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action; this._postRenderAction = action;
} }
runPostRenderAction() { runPostRenderAction() {
const im = InputManager.getInstance();
im.moveCursor(0, 0);
if (this._postRenderAction) { if (this._postRenderAction) {
this._postRenderAction.call(this); this._postRenderAction.call(this);
this._postRenderAction = undefined; this._postRenderAction = undefined;

View File

@ -1,7 +1,7 @@
import { cliPrompt } from "./prompts.ts"; import { cliPrompt } from "./prompts.ts";
import type { TerminalBlock } from "./TerminalLayout.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( export async function forceArgs(
args: string[], args: string[],

View File

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

View File

@ -4,115 +4,6 @@ import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { type CLICharEvent, InputManager } from "./InputManager.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( export async function cliPrompt(
message: string, message: string,
block?: TerminalBlock, block?: TerminalBlock,
@ -120,7 +11,6 @@ export async function cliPrompt(
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const input: string[] = []; const input: string[] = [];
let cursorPos = 0; let cursorPos = 0;
let range: [number, number] | undefined;
Cursor.show(); Cursor.show();
@ -132,15 +22,21 @@ export async function cliPrompt(
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) { if (block) {
block.setPostRenderAction(() => { block.setLines([line]);
Deno.stdout.writeSync(encoder.encode(moveTo));
});
range = block.setLines([line], range);
} else { } else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo)); 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 = () => { const exit = () => {
im.removeEventListener("enter", onEnter); im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace); im.removeEventListener("backspace", onBackspace);
@ -189,13 +85,12 @@ export async function cliPrompt(
const onKey = (e: Event) => { const onKey = (e: Event) => {
const ke = (e as CLICharEvent).detail; const ke = (e as CLICharEvent).detail;
cursorPos = im.getRelativeCursor();
input.splice(cursorPos, 0, ke.char); input.splice(cursorPos, 0, ke.char);
cursorPos++; im.updateBounds({ right: input.length + message.length + 1 });
render(); render();
}; };
render();
return await new Promise<string>((res) => { return await new Promise<string>((res) => {
resolve = res; resolve = res;
im.addEventListener("enter", onEnter); im.addEventListener("enter", onEnter);

View File

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

View File

@ -1,9 +1,9 @@
{ {
"name": "@bearmetal/pdf-tools", "name": "@bearmetal/pdf-tools",
"version": "1.0.8-l", "version": "1.0.8-p",
"license": "GPL 3.0", "license": "GPL 3.0",
"tasks": { "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", "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", "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" "debug": "deno run -A --env-file=.env --inspect-wait --watch main.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 { 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
* *
@ -772,7 +404,7 @@ class RenameFields implements ITool {
if (!this.block) { if (!this.block) {
this.block = new TerminalBlock(); this.block = new TerminalBlock();
} }
this.block.setPreserveHistory(true); this.block.setPreserveHistory(false);
[pdfPath, pattern, change] = await forceArgs( [pdfPath, pattern, change] = await forceArgs(
[pdfPath, pattern, change], [pdfPath, pattern, change],
@ -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);

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 { forceArgs } from "../cli/forceArgs.ts";
import { cliAlert } from "../cli/prompts.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts"; import { TerminalBlock } from "../cli/TerminalLayout.ts";
import { loadPdfForm } from "util/saveLoadPdf.ts"; import { loadPdfForm } from "util/saveLoadPdf.ts";
import type { ITool } from "../types.ts"; import type { ITool } from "../types.ts";
import { InputManager } from "../cli/InputManager.ts";
export class ListFormFields implements ITool { export class ListFormFields implements ITool {
name = "listformfields"; name = "listformfields";
@ -12,21 +12,48 @@ export class ListFormFields implements ITool {
if (!this.block) { if (!this.block) {
this.block = new TerminalBlock(); this.block = new TerminalBlock();
} }
this.block.setPreserveHistory(true); this.block.setPreserveHistory(false);
[pdfPath] = await forceArgs([pdfPath], [[ [pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF:", "Please provide path to PDF:",
(p) => !!p && p.endsWith(".pdf"), (p) => !!p && p.endsWith(".pdf"),
]], this.block); ]], this.block);
const lines = [pdfPath];
let rLines: string[] = [];
const form = await loadPdfForm(pdfPath); const form = await loadPdfForm(pdfPath);
const fields = form.getFields(); const fields = form.getFields();
const fieldNames = fields.map((f) => f.getName()); const fieldNames = fields.map((f) => f.getName());
const lines = [];
for (const fieldName of fieldNames) { let offset = 0;
lines.push(fieldName);
} const buildRLines = () => {
this.block.setLines(lines, [0, 1]); rLines = fieldNames.slice(offset, this.block!.getRenderHeight());
await cliAlert("", this.block); 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) { setBlock(terminalBlock: TerminalBlock) {
this.block = terminalBlock; this.block = terminalBlock;