diff --git a/cli/InputManager.ts b/cli/InputManager.ts index 16a97c6..026480a 100644 --- a/cli/InputManager.ts +++ b/cli/InputManager.ts @@ -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 { @@ -146,6 +147,12 @@ export class InputManager extends ManagerEventTarget { continue; } + if (byte === 27 && i + 1 >= n) { + this.dispatchEvent(new Event("escape")); + i++; + continue; + } + // Escape sequences if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { const code = buf[i + 2]; @@ -193,4 +200,23 @@ 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 }), + ); + } + } } diff --git a/cli/index.ts b/cli/index.ts index 042ede6..61500d4 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -14,6 +14,7 @@ const toolRegistry: [string, Promise<{ default: ITool }>][] = [ ["fieldRename", import("../tools/fieldRename.ts")], ["listFormFields", import("../tools/listFormFields.ts")], ["deleteFields", import("../tools/deleteFields.ts")], + ["fieldVisibility", import("../tools/fieldVisibility.ts")], ]; export class PdfToolsCli { diff --git a/cli/selectMenu.ts b/cli/selectMenu.ts index b4bc824..737b95c 100644 --- a/cli/selectMenu.ts +++ b/cli/selectMenu.ts @@ -1,5 +1,4 @@ import type { callback } from "../types.ts"; -import { log } from "util/logfile.ts"; import { type CLICharEvent, InputManager } from "./InputManager.ts"; import { cliLog } from "./prompts.ts"; import { colorize } from "./style.ts"; @@ -92,7 +91,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(); @@ -108,22 +117,24 @@ 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((res) => { + const final = await new Promise((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( @@ -151,10 +162,8 @@ export async function multiSelectMenuInteractive( const checkSelectAll = () => { if (selectedOptions.includes(0)) { - log("yeet"); selectedOptions = []; } else { - log("neat"); selectedOptions = Array.from(options).map((_, i) => i); } }; @@ -195,7 +204,7 @@ export async function multiSelectMenuInteractive( 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(); @@ -223,24 +232,36 @@ export async function multiSelectMenuInteractive( 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((res) => { + const selections = await new Promise((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)) { @@ -322,7 +343,7 @@ if (import.meta.main) { "yuzu", "zucchini", ], { terminalBlock: block, allOption: true }); - cliLog(val || "No value"); + cliLog(val || "No value", block); // Deno.stdout.writeSync(new TextEncoder().encode("\x07")); } diff --git a/tools/deleteFields.ts b/tools/deleteFields.ts index 04130ed..d0b1850 100644 --- a/tools/deleteFields.ts +++ b/tools/deleteFields.ts @@ -4,7 +4,6 @@ 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"; -import { log } from "util/logfile.ts"; export class DeleteFormFields implements ITool { name = "deleteFormFields"; diff --git a/tools/fieldVisibility.ts b/tools/fieldVisibility.ts new file mode 100644 index 0000000..4c3c7b1 --- /dev/null +++ b/tools/fieldVisibility.ts @@ -0,0 +1,133 @@ +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; + }), + ); + if (!fieldAndVisibility) break; + const visibility = await selectMenuInteractive( + fieldAndVisibility, + [ + "Visible", + "Hidden", + "HiddenButPrintable", + "VisibleButDoesNotPrint", + ] as AcrobatVisibility[], + ) 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) | undefined; + done?: (() => Promise | void) | undefined; + setBlock(block: TerminalBlock) { + this.block = block; + } +} + +export default new FieldVisibility();