21 Commits

Author SHA1 Message Date
65743d8562 One day I'll figure this shit out 2025-05-28 09:09:41 -06:00
0f9c377853 change: selects now use inputmanager
fix: bad exit logic
feat: field rename now supports renaming things with multiple widgets
2025-05-27 12:44:45 -06:00
7a394c642a change: input manager and prompt rewrite 2025-05-21 21:32:49 -06:00
569c67583d change: degridifies listFormField 2025-05-21 19:05:54 -06:00
123bf51001 fix: I really am thick 2025-05-21 12:55:45 -06:00
a858ea4b60 fix: I am stupid and forgot to press enter 2025-05-21 12:26:40 -06:00
b43a837c6a fix: pasting in prompt no worky 2025-05-21 12:12:37 -06:00
041129dc83 fix: arrow keys in prompts now move cursor, also implements delete key 2025-05-21 11:46:51 -06:00
89a3df17e6 fix: field rename skips saves for unmodified files 2025-05-21 11:40:17 -06:00
001b90744b fix: jsr install breaks because of missing asciiart file 2025-05-20 15:13:36 -06:00
cdeef54f68 fix: local ASCII Art inclusion 2025-05-20 13:45:40 -06:00
90f1547e02 fix: flickering eyebleed 2025-05-20 10:53:37 -06:00
e5b173155a feat: change evaluation now adds case transformation for capture groups 2025-05-20 10:22:49 -06:00
19eaf2d664 feat: field rename multiple files 2025-05-20 09:55:52 -06:00
9573291582 Merge pull request '1.0.1' (#8) from 1.0.1 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 22s
Create Version Tag / build-release (push) Successful in 2m8s
Create Version Tag / publish-release (push) Successful in 27s
Reviewed-on: #8
2025-05-07 12:30:54 -07:00
1a1431c85e chore: changelog and new readme 2025-05-07 13:28:46 -06:00
711880a670 fix: temporarily defaults asciiart to hosted file 2025-05-07 13:16:11 -06:00
4f043d2bd7 fix: banana 2025-05-07 13:07:47 -06:00
43d5916e52 fix: field rename preview 2025-05-07 12:55:19 -06:00
51aaf27fda new version 2025-05-07 10:29:15 -06:00
d5b9ef8f04 cl
All checks were successful
Create Version Tag / version-check (push) Successful in 19s
Create Version Tag / build-release (push) Has been skipped
Create Version Tag / publish-release (push) Has been skipped
2025-05-07 09:53:02 -06:00
18 changed files with 1671 additions and 343 deletions

View File

@@ -30,7 +30,7 @@ jobs:
uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/deno-release@main uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/deno-release@main
with: with:
entrypoint: main.ts entrypoint: main.ts
compile-flags: "--allow-read --allow-write --allow-env --allow-net" compile-flags: "--allow-read --allow-write --allow-env --allow-net --include asciiart.txt"
env: env:
GITEA_TOKEN: ${{ secrets.GIT_PAT }} GITEA_TOKEN: ${{ secrets.GIT_PAT }}

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@
.env .env
log.txt log.txt
log log
test2.pdf

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
## v1.0.2 (2025-05-20)
<!-- auto-changelog -->
## v1.0.1 (2025-05-7)
<!-- auto-changelog -->
### Known Issues
- help flags can cause issues
## v1.0.0 (2025-05-7)
### Features
- Check Code Tool
- reads supplied code files to see if form fields are present and represented
in a switch statement
- Field Rename Tool
- provide a search and replace pattern to bulk rename form fields
- List Form Fields
- Sometimes you just need to see what fields there are
### Known Issues
- Field rename does not represent full change applied to the field, only the
replaced text
- help flags can cause issues
- ascii art is broken (sad face)
- banana doesn't work
## v0.0.0 (never)
this is just here for a reference to the auto-changelog
<!-- auto-changelog -->

View File

@@ -1,7 +1,16 @@
# Emma's Simple Form Field Checker # BearMetal PDF Tools
Compares a PDF form to a list of CS class files to see if all field names are A collection of tools for working with PDF forms.
present, excluding signature fields.
## Features
- Check Code Tool
- reads supplied code files to see if form fields are present and represented
in a switch statement
- Field Rename Tool
- provide a search and replace pattern to bulk rename form fields
- List Form Fields
- Sometimes you just need to see what fields there are
## Prereqs ## Prereqs
@@ -11,7 +20,8 @@ Deno >=2.2 (not required if downloading .exe)
### Deno install ### Deno install
`deno task install` -> installs as global command `checkfields` `deno install -g --allow-read --allow-write --allow-net --allow-env jsr:@bearmetal/pdf-tools`
-> installs as global command `pdf-tools`
### Compile ### Compile
@@ -20,17 +30,51 @@ Deno >=2.2 (not required if downloading .exe)
> If you want it to be a global command, create an executables directory and add > If you want it to be a global command, create an executables directory and add
> it to the PATH > it to the PATH
### Precompiled
Download the latest release from the
[releases page](https://git.cyborggrizzly.com/BearMetal/pdf-tools/releases)
## Usage ## Usage
`checkfields <path to PDF> <comma-separated list of paths to CS class files>` `pdf-tools <tool> <args>` -> `<tool>` is one of the following
-OR- `checkfields` and follow prompts.
### Output - check-code
- field-rename
- list-form-fields
> All form fields present! ## Contributing
-OR- Contributions are welcome!
> The following field names are not present in the CS code ## License
> \<list of missing form fields\> GPL 3.0
---
### About this project
BearMetal PDF Tools is a collection of tools made to fix the current state of
PDF form editing. Adobe Acrobat is a great tool, but it's not always the easiest
to use, nor is it free. It also lacks some features that I find useful, such as
bulk renaming of form fields. There's also a lack of powerful, free, and open
source tools for PDF editing.
This project aims to fill that gap by providing a set of tools that can be used
to edit PDF forms. The tools are written in Deno, a modern and secure runtime
for JavaScript and TypeScript. They are designed to be easy to use and to
provide a great user experience.
The tools are designed to be used in a terminal, and are not designed to be
integrated into other applications. They are also not designed to be used as a
library, but rather as a command line tool.
### About BearMetal
BearMetal is a project that aims to decrapify modern web development. It is a
collection of tools, libraries, and resources that aim to make web development
more accessible and less intimidating. The project is open source and free to
use, and is designed to be used by anyone, regardless of skill level or
experience. You can find a list of libraries and tools on
[JSR](https://jsr.io/@bearmetal).

196
cli/InputManager.ts Normal file
View File

@@ -0,0 +1,196 @@
interface EventMap {
keypress: CLIKeypressEvent;
char: CLICharEvent;
activate: Event;
deactivate: Event;
exit: Event;
enter: Event;
backspace: Event;
delete: Event;
"arrow-left": Event;
"arrow-right": Event;
"arrow-up": Event;
"arrow-down": Event;
[key: string]: Event;
}
interface EventDetailMap {
keypress: {
key: number;
sequence?: Uint8Array;
};
char: EventDetailMap["keypress"] & {
char: string;
};
}
export type TypedEventTarget<EventMap extends object> = {
new (): IntermediateEventTarget<EventMap>;
};
export interface IntermediateEventTarget<EventMap> extends EventTarget {
addEventListener<K extends keyof EventMap>(
type: K,
listener: (
event: EventMap[K] extends Event ? EventMap[K] : Event,
) => EventMap[K] extends Event ? void : never,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof EventMap>(
type: K,
listener: (
event: EventMap[K] extends Event ? EventMap[K] : Event,
) => EventMap[K] extends Event ? void : never,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
}
const ManagerEventTarget = EventTarget as TypedEventTarget<EventMap>;
export class CLICharEvent extends CustomEvent<EventDetailMap["char"]> {
constructor(detail: EventDetailMap["char"]) {
super("char", { detail, cancelable: true });
}
}
export class CLIKeypressEvent extends CustomEvent<EventDetailMap["keypress"]> {
constructor(detail: EventDetailMap["keypress"]) {
super("keypress", { detail, cancelable: true });
}
}
export class InputManager extends ManagerEventTarget {
private static instance = new InputManager();
private active = false;
static getInstance(): InputManager {
return this.instance ??= new InputManager();
}
static addEventListener = InputManager.prototype.addEventListener.bind(
this.instance,
);
static removeEventListener = InputManager.prototype.removeEventListener.bind(
this.instance,
);
static dispatchEvent = InputManager.prototype.dispatchEvent.bind(
this.instance,
);
activate({ raw = true }: { raw?: boolean } = {}) {
if (this.active) return;
this.active = true;
this.dispatchEvent(new Event("activate"));
this.listen(raw);
}
deactivate({ dactivateRaw = true }: { dactivateRaw?: boolean } = {}) {
if (!this.active) return;
this.active = false;
this.dispatchEvent(new Event("deactivate"));
if (dactivateRaw) Deno.stdin.setRaw(false);
}
once<T extends string>(type: T): Promise<Event> {
return new Promise((resolve) => {
const handler = (event: Event) => {
this.removeEventListener(type, handler);
resolve(event);
};
this.addEventListener(type, handler);
});
}
private async listen(raw: boolean) {
if (raw) await Deno.stdin.setRaw(true);
const buf = new Uint8Array(64);
while (this.active) {
const n = await Deno.stdin.read(buf);
if (n === null) break;
let i = 0;
while (i < n) {
const byte = buf[i];
// Ctrl+C
if (byte === 3) {
this.dispatchEvent(new Event("exit"));
await Deno.stdin.setRaw(false);
Deno.exit(130);
}
// Enter
if (byte === 13) {
this.dispatchEvent(new Event("enter"));
i++;
continue;
}
// Backspace
if (byte === 127 || byte === 8) {
this.dispatchEvent(new Event("backspace"));
i++;
continue;
}
// Escape sequences
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2];
switch (code) {
case 65:
this.dispatchEvent(new Event("arrow-up"));
break;
case 66:
this.dispatchEvent(new Event("arrow-down"));
break;
case 67:
this.dispatchEvent(new Event("arrow-right"));
break;
case 68:
this.dispatchEvent(new Event("arrow-left"));
break;
case 51:
if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete"));
i += 4;
continue;
}
break;
}
i += 3;
continue;
}
// Printable ASCII
if (byte >= 32 && byte <= 126) {
this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
);
i++;
continue;
}
// Unknown
this.dispatchEvent(
new CLIKeypressEvent({ key: byte, sequence: buf.slice(i, i + 1) }),
);
i++;
}
}
if (raw) await Deno.stdin.setRaw(false);
}
}

View File

@@ -22,7 +22,7 @@ export class TerminalLayout {
Deno.addSignalListener("SIGINT", () => { Deno.addSignalListener("SIGINT", () => {
this.clearAll(); this.clearAll();
Deno.exit(0); // Deno.exit(0);
}); });
} }
@@ -44,7 +44,9 @@ export class TerminalLayout {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
} }
this.debounceTimer = setTimeout( this.debounceTimer = setTimeout(
() => this.renderLayout(), () => {
this.renderLayout();
},
this.debounceDelay, this.debounceDelay,
); );
} }
@@ -76,6 +78,10 @@ export class TerminalLayout {
block.renderInternal(usedLines + 1); block.renderInternal(usedLines + 1);
usedLines += lines.length; usedLines += lines.length;
} }
for (const name of this.layoutOrder) {
const block = this.blocks[name];
block.runPostRenderAction?.();
}
} }
clearAll() { clearAll() {
@@ -111,9 +117,12 @@ export class TerminalBlock {
private renderHeight: number = 0; private renderHeight: number = 0;
private lastRenderRow = 1; private lastRenderRow = 1;
private lastRendered: string[] = [];
private preserveHistory = false; private preserveHistory = false;
constructor(private prepend: string = "") {} constructor(private prepend: string = "") {
}
setPreserveHistory(preserveHistory: boolean) { setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory; this.preserveHistory = preserveHistory;
@@ -193,27 +202,39 @@ export class TerminalBlock {
} }
renderInternal(startRow?: number) { renderInternal(startRow?: number) {
this.lastRenderRow = startRow ?? this.lastRenderRow; const outputLines: string[] = [];
this.clear(); // uses old renderedLineCount
const outputLines = this.renderLines.map((line) => for (let i = 0; i < this.renderLines.length; i++) {
`${this.prepend}${line}\x1b[K` const line = `${this.prepend}${this.renderLines[i]}`;
); const previous = this.lastRendered[i];
const output = outputLines.join("\n"); if (line !== previous) {
if (startRow !== undefined) { const moveToLine = `\x1b[${(startRow ?? this.lastRenderRow) + i};1H`;
const moveCursor = `\x1b[${startRow};1H`; outputLines.push(moveToLine + line + "\x1b[K");
Deno.stdout.writeSync(new TextEncoder().encode(moveCursor + output)); }
} else {
Deno.stdout.writeSync(new TextEncoder().encode(output));
} }
// update rendered line count *after* rendering if (this.lastRendered.length > this.renderLines.length) {
this.renderedLineCount = outputLines.reduce( const baseRow = startRow ?? this.lastRenderRow;
(count, line) => for (let i = this.renderLines.length; i < this.lastRendered.length; i++) {
count + const moveToLine = `\x1b[${baseRow + i};1H\x1b[2K`;
Math.ceil((line.length) / (Deno.consoleSize().columns || 80)), Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
0, }
); }
const baseRow = startRow ?? this.lastRenderRow;
const excessLines = this.renderHeight - this.renderLines.length;
for (let i = 0; i < excessLines; i++) {
const moveToLine = `\x1b[${
baseRow + this.renderLines.length + i
};1H\x1b[2K`;
Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
}
this.lastRendered = [...this.renderLines];
this.renderedLineCount = this.renderHeight;
this.lastRenderRow = baseRow;
const output = outputLines.join("\n");
Deno.stdout.writeSync(new TextEncoder().encode(output));
} }
clear() { clear() {
@@ -242,6 +263,17 @@ export class TerminalBlock {
return this.fixedHeight ?? 0; return this.fixedHeight ?? 0;
} }
private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action;
}
runPostRenderAction() {
if (this._postRenderAction) {
this._postRenderAction.call(this);
this._postRenderAction = undefined;
}
}
get lineCount() { get lineCount() {
return this.renderLines.length; return this.renderLines.length;
} }

View File

@@ -6,8 +6,7 @@ import { selectMenuInteractive } from "./selectMenu.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { cliAlert, cliLog } from "./prompts.ts"; import { cliAlert, cliLog } from "./prompts.ts";
import type { ITool } from "../types.ts"; import type { ITool } from "../types.ts";
import { join, toFileUrl } from "@std/path"; import { InputManager } from "./InputManager.ts";
import { log } from "util/logfile.ts";
// Register tools here (filename, no extension) // Register tools here (filename, no extension)
const toolRegistry: [string, Promise<{ default: ITool }>][] = [ const toolRegistry: [string, Promise<{ default: ITool }>][] = [
@@ -23,6 +22,7 @@ export class PdfToolsCli {
private args = ArgParser.parse(Deno.args).setFlagDefs({ private args = ArgParser.parse(Deno.args).setFlagDefs({
help: ["-h", "--help"], help: ["-h", "--help"],
banana: ["-b", "--banana"],
}); });
async importTools() { async importTools() {
@@ -43,9 +43,7 @@ export class PdfToolsCli {
private async banana() { private async banana() {
const asciiArt = await getAsciiArt("banana"); const asciiArt = await getAsciiArt("banana");
const body = this.terminalLayout.getBlock("body"); this.closeMessage = colorize(asciiArt, "yellow");
body.clearAll();
cliLog(colorize(asciiArt, "yellow"), body);
} }
private async help() { private async help() {
@@ -58,12 +56,23 @@ export class PdfToolsCli {
} }
public async run() { public async run() {
const im = InputManager.getInstance();
im.activate();
im.addEventListener("exit", () => {
this.closeMessage = "Exiting...";
this.cleanup();
});
try { try {
await this.importTools(); await this.importTools();
const titleBlock = new TerminalBlock(); const titleBlock = new TerminalBlock();
this.terminalLayout.register("title", titleBlock); this.terminalLayout.register("title", titleBlock);
const bodyBlock = new TerminalBlock(); const bodyBlock = new TerminalBlock();
this.terminalLayout.register("body", bodyBlock); this.terminalLayout.register("body", bodyBlock);
if (this.args.getFlag("banana")) {
titleBlock.setFixedHeight(0);
await this.banana();
return;
}
if (this.args.getFlag("help") && !this.args.task) { if (this.args.getFlag("help") && !this.args.task) {
await this.help(); await this.help();
return; return;
@@ -75,12 +84,18 @@ export class PdfToolsCli {
await this.runTool(toCase(task, "title")); await this.runTool(toCase(task, "title"));
} }
} finally { } finally {
this.terminalLayout.clearAll(); this.cleanup();
Deno.stdin.setRaw(false); Deno.exit(0);
if (this.closeMessage) console.log(this.closeMessage);
} }
} }
private cleanup() {
this.terminalLayout.clearAll();
InputManager.getInstance().deactivate();
Deno.stdin.setRaw(false);
if (this.closeMessage) console.log(this.closeMessage);
}
private async toolMenu() { private async toolMenu() {
const tools = this.tools.keys().toArray(); const tools = this.tools.keys().toArray();
const bodyBlock = this.terminalLayout.getBlock("body"); const bodyBlock = this.terminalLayout.getBlock("body");

View File

@@ -2,6 +2,116 @@
import { Cursor } from "./cursor.ts"; import { Cursor } from "./cursor.ts";
import { colorize } from "./style.ts"; import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.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( export async function cliPrompt(
message: string, message: string,
@@ -9,70 +119,141 @@ export async function cliPrompt(
): Promise<string> { ): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const input: string[] = []; const input: string[] = [];
let cursorPos = 0;
let range: [number, number] | undefined;
await Deno.stdin.setRaw(true);
const cursorVisible = Cursor["visible"];
Cursor.show(); Cursor.show();
let range: [number, number] = [0, 1]; const im = InputManager.getInstance();
if (block) { im.activate();
range = block.setLines([message + " "]);
} else {
Deno.stdout.writeSync(encoder.encode(message + " "));
}
const buf = new Uint8Array(1);
while (true) {
const n = await Deno.stdin.read(buf);
if (n === null) break;
const byte = buf[0];
if (byte === 3) { // Ctrl+C
Deno.stdin.setRaw(false);
block?.["layout"]?.clearAll();
block?.clear();
Deno.exit(130);
}
if (byte === 13) { // Enter
break;
} else if (byte === 127 || byte === 8) { // Backspace
input.pop();
} else if (byte >= 32 && byte <= 126) { // Printable chars
input.push(String.fromCharCode(byte));
}
const render = () => {
const line = message + " " + input.join(""); const line = message + " " + input.join("");
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) { if (block) {
block.setPostRenderAction(() => {
Deno.stdout.writeSync(encoder.encode(moveTo));
});
range = block.setLines([line], range); range = block.setLines([line], range);
} else { } else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line)); Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo));
}
};
const exit = () => {
im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("delete", onDelete);
im.removeEventListener("arrow-left", onLeft);
im.removeEventListener("arrow-right", onRight);
im.removeEventListener("char", onKey);
Cursor.hide();
};
let resolve: null | ((value: string) => void) = null;
const onEnter = () => {
exit();
resolve?.(input.join(""));
};
const onBackspace = () => {
if (cursorPos > 0) {
input.splice(cursorPos - 1, 1);
cursorPos--;
render();
}
};
const onDelete = () => {
if (cursorPos < input.length) {
input.splice(cursorPos, 1);
render();
}
};
const onLeft = () => {
if (cursorPos > 0) {
cursorPos--;
render();
}
};
const onRight = () => {
if (cursorPos < input.length) {
cursorPos++;
render();
}
};
const onKey = (e: Event) => {
const ke = (e as CLICharEvent).detail;
input.splice(cursorPos, 0, ke.char);
cursorPos++;
render();
};
render();
return await new Promise<string>((res) => {
resolve = res;
im.addEventListener("enter", onEnter);
im.addEventListener("backspace", onBackspace);
im.addEventListener("delete", onDelete);
im.addEventListener("arrow-left", onLeft);
im.addEventListener("arrow-right", onRight);
im.addEventListener("char", onKey);
});
}
export async function cliConfirm(message: string, block?: TerminalBlock) {
const im = InputManager.getInstance();
let inpout = "";
function isValidInput(input: string) {
switch (input) {
case "y":
case "n":
return inpout.length === 0;
case "e":
return inpout === "y";
case "s":
return inpout === "ye";
case "o":
return inpout === "n";
default:
return false;
} }
} }
await Deno.stdin.setRaw(false); function onKey(e: CLICharEvent) {
if (!cursorVisible) { const ke = e.detail;
Cursor.hide(); const char = String.fromCharCode(ke.key);
if (isValidInput(char)) {
inpout += char;
} else {
e.stopImmediatePropagation();
}
} }
Deno.stdout.writeSync(encoder.encode("\n")); im.addEventListener("char", onKey);
const value = await cliPrompt(message + " (y/n)", block).then((v) =>
return input.join(""); v.charAt(0).toLowerCase() === "y"
}
export function cliConfirm(message: string, block?: TerminalBlock) {
return cliPrompt(message + " (y/n)", block).then((v) =>
v.toLowerCase() === "y"
); );
im.removeEventListener("char", onKey);
return value;
} }
export function cliAlert(message: string, block?: TerminalBlock) { export async function cliAlert(message: string, block?: TerminalBlock) {
return cliPrompt( const im = InputManager.getInstance();
const onKey = (e: CLICharEvent) => {
e.stopImmediatePropagation();
};
im.addEventListener("char", onKey);
await cliPrompt(
message + colorize(" Press Enter to continue", "gray"), message + colorize(" Press Enter to continue", "gray"),
block, block,
).then((v) => { );
return v; im.removeEventListener("char", onKey);
});
} }
export function cliLog( export function cliLog(
@@ -92,18 +273,29 @@ if (import.meta.main) {
const layout = new TerminalLayout(); const layout = new TerminalLayout();
const title = new TerminalBlock(); const title = new TerminalBlock();
const block = new TerminalBlock(); const block = new TerminalBlock();
const footer = new TerminalBlock();
block.setPreserveHistory(true); block.setPreserveHistory(true);
// ScrollManager.enable(block); // ScrollManager.enable(block);
title.setLines(["Hello, World!"]); title.setLines(["Hello, World!"]);
title.setFixedHeight(1); title.setFixedHeight(1);
footer.setLines(["Press Ctrl+C to exit"]);
footer.setFixedHeight(1);
layout.register("title", title); layout.register("title", title);
layout.register("block", block); layout.register("block", block);
layout.register("footer", footer);
InputManager.addEventListener("exit", () => {
layout.clearAll();
// console.clear();
Deno.exit(0);
});
Deno.addSignalListener("SIGINT", () => { Deno.addSignalListener("SIGINT", () => {
layout.clearAll(); layout.clearAll();
// console.clear(); // console.clear();
Deno.exit(0); // Deno.exit(0);
}); });
const name = await cliPrompt("Enter your name:", block); const name = await cliPrompt("Enter your name:", block);
cliLog(`Hello, ${name}!`, block); cliLog(`Hello, ${name}!`, block);

View File

@@ -1,6 +1,8 @@
import type { callback } from "../types.ts"; import type { callback } from "../types.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
import { cliLog } from "./prompts.ts";
import { colorize } from "./style.ts"; import { colorize } from "./style.ts";
import { TerminalBlock } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
interface ISelectMenuConfig { interface ISelectMenuConfig {
terminalBlock?: TerminalBlock; terminalBlock?: TerminalBlock;
@@ -62,50 +64,65 @@ export async function selectMenuInteractive(
let inputBuffer = ""; let inputBuffer = "";
// Function to handle input const im = InputManager.getInstance();
async function handleInput() { im.activate();
const buf = new Uint8Array(3); // arrow keys send 3 bytes
while (true) {
renderMenu();
const n = await Deno.stdin.read(buf);
if (n === null) break;
const [a, b, c] = buf; const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
if (a === 3) { const onDown = (e: Event) => {
Deno.stdin.setRaw(false); e.stopImmediatePropagation();
terminalBlock?.["layout"]?.clearAll(); selected = (selected + 1) % options.length;
Deno.exit(130); renderMenu();
} };
if (a === 13) { // Enter key const onKey = (e: CLICharEvent) => {
if (inputBuffer) { e.stopImmediatePropagation();
const parsed = parseInt(inputBuffer); const ke = e.detail;
if (!isNaN(parsed)) { const char = String.fromCharCode(ke.key);
selected = parsed - 1; inputBuffer += char;
} };
inputBuffer = "";
} const onBackspace = (e: Event) => {
break; e.stopImmediatePropagation();
} else if (a === 27 && b === 91) { // Arrow keys inputBuffer = inputBuffer.slice(0, -1);
inputBuffer = ""; };
if (c === 65) { // Up
selected = (selected - 1 + options.length) % options.length; let resolve: null | ((value: string) => void) = null;
} else if (c === 66) { // Down
selected = (selected + 1) % options.length; const onEnter = (e: Event) => {
} e.stopImmediatePropagation();
} else if (a >= 48 && a <= 57) { if (inputBuffer) {
inputBuffer += String.fromCharCode(a); const parsed = parseInt(inputBuffer);
} else if (a === 8) { if (!isNaN(parsed)) {
inputBuffer = inputBuffer.slice(0, -1); selected = parsed - 1;
} }
inputBuffer = "";
} }
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
resolve?.(options[selected]);
};
renderMenu();
await new Promise<string>((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);
});
Deno.stdin.setRaw(false);
return options[selected];
}
terminalBlock.setLines(["Selected: " + options[selected]], range); terminalBlock.setLines(["Selected: " + options[selected]], range);
return await handleInput();
return options[selected];
} }
export async function multiSelectMenuInteractive( export async function multiSelectMenuInteractive(
@@ -117,9 +134,7 @@ export async function multiSelectMenuInteractive(
let selected = 0; let selected = 0;
let selectedOptions: number[] = config?.initialSelections || []; let selectedOptions: number[] = config?.initialSelections || [];
const rawValues = new Set( const rawValues = options.map((i) => typeof i === "string" ? i : i[0]);
options.map((i) => typeof i === "string" ? i : i[0]),
).values().toArray();
if (rawValues.length !== options.length) { if (rawValues.length !== options.length) {
throw new Error("Duplicate options in multi-select menu"); throw new Error("Duplicate options in multi-select menu");
@@ -158,44 +173,52 @@ export async function multiSelectMenuInteractive(
range = terminalBlock.setLines(lines, range); range = terminalBlock.setLines(lines, range);
} }
// Function to handle input const im = InputManager.getInstance();
async function handleInput() { im.activate();
const buf = new Uint8Array(3); // arrow keys send 3 bytes
while (true) {
renderMenu();
const n = await Deno.stdin.read(buf);
if (n === null) break;
const [a, b, c] = buf; let resolve = null as null | ((value: number[]) => void);
if (a === 3) { const onUp = (e: Event) => {
Deno.stdin.setRaw(false); e.stopImmediatePropagation();
terminalBlock?.["layout"]?.clearAll(); selected = (selected - 1 + options.length) % options.length;
Deno.exit(130); renderMenu();
} };
if (a === 13) { // Enter key const onDown = (e: Event) => {
break; e.stopImmediatePropagation();
} else if (a === 27 && b === 91) { // Arrow keys selected = (selected + 1) % options.length;
if (c === 65) { // Up renderMenu();
selected = (selected - 1 + options.length) % options.length; };
} else if (c === 66) { // Down
selected = (selected + 1) % options.length; const onSpace = (e: CLICharEvent) => {
} if (e.detail.char !== " ") return;
} else if (a === 32) { // Space e.stopImmediatePropagation();
Deno.stdout.writeSync(new TextEncoder().encode("\x07")); if (selectedOptions.includes(selected)) {
if (selectedOptions.includes(selected)) { selectedOptions = selectedOptions.filter((i) => i !== selected);
selectedOptions = selectedOptions.filter((i) => i !== selected); } else {
} else { selectedOptions.push(selected);
selectedOptions.push(selected);
}
}
} }
renderMenu();
};
Deno.stdin.setRaw(false); const onEnter = (e: Event) => {
return selectedOptions; e.stopImmediatePropagation();
} resolve?.(selectedOptions);
const selections = await handleInput(); im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
};
renderMenu();
const selections = await new Promise<number[]>((res) => {
resolve = res;
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter);
});
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)) {
@@ -208,17 +231,17 @@ export async function multiSelectMenuInteractive(
} }
if (import.meta.main) { if (import.meta.main) {
// const layout = new TerminalLayout(); const layout = new TerminalLayout();
// const block = new TerminalBlock(); const block = new TerminalBlock();
// const titleBlock = new TerminalBlock(); const titleBlock = new TerminalBlock();
// const postBlock = new TerminalBlock(); const postBlock = new TerminalBlock();
// titleBlock.setLines(["An incredible fruit menu!"]); InputManager.addEventListener("exit", () => layout.clearAll());
// postBlock.setLines(["I'm here too!"]); titleBlock.setLines(["An incredible fruit menu!"]);
// titleBlock.setFixedHeight(1); postBlock.setLines(["I'm here too!"]);
// postBlock.setFixedHeight(1); titleBlock.setFixedHeight(1);
// layout.register("title", titleBlock); postBlock.setFixedHeight(1);
// layout.register("block", block); layout.register("title", titleBlock);
// layout.register("post", postBlock); layout.register("block", block);
// const val = await selectMenuInteractive("choose a fruit", [ // const val = await selectMenuInteractive("choose a fruit", [
// "apple", // "apple",
@@ -276,8 +299,8 @@ if (import.meta.main) {
"ximenia", "ximenia",
"yuzu", "yuzu",
"zucchini", "zucchini",
]); ], { terminalBlock: block });
console.log(val); cliLog(val || "No value");
// Deno.stdout.writeSync(new TextEncoder().encode("\x07")); // Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
} }

View File

@@ -1,10 +1,10 @@
{ {
"name": "@bearmetal/pdf-tools", "name": "@bearmetal/pdf-tools",
"version": "1.0.0", "version": "1.0.8-l",
"license": "GPL 3.0", "license": "GPL 3.0",
"tasks": { "tasks": {
"dev": "deno run -A --env-file=.env --watch main.ts", "dev": "deno run -A --env-file=.env main.ts",
"compile": "deno compile -o compare-form-fields.exe --target x86_64-pc-windows-msvc -R ./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"
}, },
@@ -12,7 +12,8 @@
"@std/assert": "jsr:@std/assert@1", "@std/assert": "jsr:@std/assert@1",
"@std/path": "jsr:@std/path@^1.0.9", "@std/path": "jsr:@std/path@^1.0.9",
"pdf-lib": "npm:pdf-lib@^1.17.1", "pdf-lib": "npm:pdf-lib@^1.17.1",
"util/": "./util/" "util/": "./util/",
"@/": "./"
}, },
"exports": { "exports": {
".": "./main.ts" ".": "./main.ts"

View File

@@ -1,5 +1,10 @@
/// <reference types="./types.ts" /> /// <reference types="./types.ts" />
import { PdfToolsCli } from "./cli/index.ts"; import { PdfToolsCli } from "./cli/index.ts";
// import { log } from "util/logfile.ts";
// try {
const app = new PdfToolsCli(); const app = new PdfToolsCli();
app.run(); app.run();
// } catch (e) {
// // log(e);
// }

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -20,31 +20,10 @@ export class ListFormFields implements ITool {
const form = await loadPdfForm(pdfPath); const form = await loadPdfForm(pdfPath);
const fields = form.getFields(); const fields = form.getFields();
const height = this.block.getRenderHeight() - 1; const fieldNames = fields.map((f) => f.getName());
const fieldNames = fields.sort((a, b) => {
const aRect = a.acroField.getWidgets().find((e) => e.Rect())?.Rect()
?.asRectangle();
const bRect = b.acroField.getWidgets().find((e) => e.Rect())?.Rect()
?.asRectangle();
if (aRect && bRect) {
if (aRect.x !== bRect.x) {
return aRect.x - bRect.x; // Sort left to right
} else {
return bRect.y - aRect.y; // If x is equal, sort top to bottom
}
}
return a.getName().localeCompare(b.getName());
}).map((f) => f.getName());
const maxLength = Math.max(...fieldNames.map((f) => f.length)) + 4;
const lines = []; const lines = [];
for (let i = 0; i < height; i++) { for (const fieldName of fieldNames) {
let line = ""; lines.push(fieldName);
for (let j = 0; j < fieldNames.length; j += height) {
const fieldName = fieldNames[i + j] ?? "";
line += fieldName.padEnd(maxLength, " ");
}
lines.push(line);
} }
this.block.setLines(lines, [0, 1]); this.block.setLines(lines, [0, 1]);
await cliAlert("", this.block); await cliAlert("", this.block);

View File

@@ -1,30 +1,30 @@
export async function getAsciiArt(art: string) { import { join } from "@std/path";
const artFilePath = Deno.env.get("BEARMETAL_ASCII_PATH") ||
getBearmetalAsciiPath();
if (!artFilePath) return art;
let artFileText: string;
if (artFilePath.startsWith("http")) {
artFileText = await fetch(artFilePath).then((res) => res.text());
} else {
artFileText = await Deno.readTextFile(artFilePath);
}
const parserRX = /begin\s+(\w+)\s*\n([\s\S]*?)\s*end\s*/g;
let result = parserRX.exec(artFileText);
while (result !== null) { export async function getAsciiArt(art: string) {
const [_, name, artText] = result; try {
if (name === art) return artText; const artFilePath =
result = parserRX.exec(artFileText); Deno.env.get("BEARMETAL_ASCII_PATH") || import.meta.dirname
? join(import.meta.dirname || "", "../asciiart.txt")
: "https://git.cyborggrizzly.com/BearMetal/pdf-tools/raw/branch/main/asciiart.txt";
let artFileText: string;
if (artFilePath?.startsWith("http")) {
artFileText = await fetch(artFilePath).then((res) => res.text());
} else {
artFileText = await Deno.readTextFile(
artFilePath,
);
}
const parserRX = /begin\s+(\w+)\s*\n([\s\S]*?)\s*end\s*/g;
let result = parserRX.exec(artFileText);
while (result !== null) {
const [_, name, artText] = result;
if (name === art) return artText;
result = parserRX.exec(artFileText);
}
} catch (e) {
console.log(e);
alert();
} }
return art; return art;
} }
function getBearmetalAsciiPath() {
const filenameRX = /asciiarts?\.txt$/;
for (const filename of Deno.readDirSync(".")) {
if (filename.isFile && filenameRX.test(filename.name)) {
return filename.name;
}
}
return null;
}

View File

@@ -11,9 +11,45 @@ function lowerToTrainCase(str: string) {
); );
} }
function lowerToCamelCase(str: string) { /**
return str.trim().replace(/(?:\s)\w/g, (match) => match.toUpperCase()) * @param str
.replaceAll(" ", ""); * @returns camelCased string (single letter words are lower cased, e.g. SSN -> ssn)
*/
function lowerToCamelCase(str: string): string {
const words = str.trim().split(/\s+/);
const result: string[] = [];
let i = 0;
while (i < words.length) {
if (words[i].length === 1) {
// Weve hit the start of a chain of single-letter words
let j = i;
while (j < words.length && words[j].length === 1) {
j++;
}
const chainIsAtStart = i === 0;
// Process that entire chain
for (let k = i; k < j; k++) {
result[k] = chainIsAtStart
? words[k].toLowerCase()
: words[k].toUpperCase();
}
i = j;
} else {
// Normal multi-letter word
if (i === 0) {
// first word: all lower
result[i] = words[i].toLowerCase();
} else {
// subsequent words: capitalize first letter
result[i] = words[i][0].toUpperCase() +
words[i].slice(1).toLowerCase();
}
i++;
}
}
return result.join("");
} }
function lowerToSnakeCase(str: string) { function lowerToSnakeCase(str: string) {
@@ -88,10 +124,10 @@ function coerceCaseToLower(str: string, caseType: CaseType) {
case "macro": case "macro":
case "snake": case "snake":
case "upper": case "upper":
return str.replace("_", " ").toLowerCase(); return str.replaceAll("_", " ").toLowerCase();
case "train": case "train":
case "kebab": case "kebab":
return str.replace("-", " ").toLowerCase(); return str.replaceAll("-", " ").toLowerCase();
default: default:
return str.toLowerCase(); return str.toLowerCase();
} }
@@ -124,3 +160,7 @@ export function toCase(str: string, toCase: CaseType) {
return str; return str;
} }
} }
if (import.meta.main) {
console.log(toCase("SSN", "camel"));
}

View File

@@ -7,9 +7,9 @@ const logFile = Deno.openSync("./log.txt", {
logFile.truncateSync(0); logFile.truncateSync(0);
export function log(message: any) { export function log(...message: any) {
if (typeof message === "object") { if (typeof message === "object") {
message = JSON.stringify(message); message = Deno.inspect(message);
} }
logFile.writeSync(new TextEncoder().encode(message + "\n")); logFile.writeSync(new TextEncoder().encode(message + "\n"));
} }

View File

@@ -15,10 +15,10 @@ export async function loadPdf(path: string) {
export async function savePdf(doc: PDFDocument, path: string) { export async function savePdf(doc: PDFDocument, path: string) {
doc.getForm().getFields().forEach((field) => { doc.getForm().getFields().forEach((field) => {
if (field instanceof PDFTextField) { if (field instanceof PDFTextField) {
field.disableRichFormatting(); field.disableRichFormatting?.();
} }
}); });
const pdfBytes = await doc.save(); const pdfBytes = await doc.save({ updateFieldAppearances: true });
if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return; if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return;
await Deno.writeFile(path, pdfBytes); await Deno.writeFile(path, pdfBytes);
} }