52 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
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
22487224f2 fetch depth
All checks were successful
Create Version Tag / version-check (push) Successful in 20s
Create Version Tag / build-release (push) Successful in 2m25s
Create Version Tag / publish-release (push) Successful in 30s
2025-05-07 00:32:25 -06:00
6cc772bbf2 asdf
Some checks failed
Create Version Tag / version-check (push) Successful in 19s
Create Version Tag / build-release (push) Failing after 18s
Create Version Tag / publish-release (push) Has been skipped
2025-05-06 23:45:52 -06:00
430130cdaf asdf
Some checks failed
Create Version Tag / version-check (push) Failing after 18s
Create Version Tag / build-release (push) Has been skipped
Create Version Tag / publish-release (push) Has been skipped
2025-05-06 23:40:23 -06:00
9b11f14c84 fix ci
Some checks failed
Create Version Tag / version-check (push) Failing after 18s
Create Version Tag / build-release (push) Has been skipped
Create Version Tag / publish-release (push) Has been skipped
2025-05-06 23:25:15 -06:00
25378d2d3c unified ci workflow
Some checks failed
Create Version Tag / version-check (push) Successful in 20s
Create Version Tag / build-release (push) Has been skipped
Create Version Tag / publish-release (push) Failing after 17s
2025-05-06 23:13:53 -06:00
237d4c4349 whatever
All checks were successful
Create Version Tag / publish (push) Successful in 19s
2025-05-06 22:51:24 -06:00
b0fe668133 chore: trigger ci
Some checks failed
Create Version Tag / publish (push) Failing after 17s
2025-05-06 22:49:13 -06:00
7ee7d5f291 Merge pull request 'please for the love of god' (#7) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 17s
Reviewed-on: #7
2025-05-06 21:11:36 -07:00
37f7a58f96 please for the love of god 2025-05-06 22:10:56 -06:00
4f4aee6a3e Merge pull request 'I'm tired boss' (#6) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 14s
Reviewed-on: #6
2025-05-06 20:00:46 -07:00
4691ddc745 I'm tired boss 2025-05-06 21:00:16 -06:00
fa44985594 Merge pull request 'idek' (#5) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 8s
Reviewed-on: #5
2025-05-06 19:17:38 -07:00
53cb40ebe8 idek 2025-05-06 20:16:56 -06:00
2113f930a7 Merge pull request 'asdf' (#4) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 7s
Reviewed-on: #4
2025-05-06 18:54:14 -07:00
680aae8b4f asdf 2025-05-06 19:52:08 -06:00
969de4aab7 Merge pull request 'again' (#3) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 8s
Reviewed-on: #3
2025-05-06 18:47:59 -07:00
673424d755 again 2025-05-06 19:47:18 -06:00
593f853143 Merge pull request 'add tokens' (#2) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 7s
Reviewed-on: #2
2025-05-06 18:42:18 -07:00
490b948576 add tokens 2025-05-06 19:40:49 -06:00
7972e679ab Merge pull request 'dev' (#1) from dev into main
Some checks failed
Create Version Tag / publish (push) Failing after 7s
Reviewed-on: #1
2025-05-06 18:24:49 -07:00
91eb569d4b bump version 2025-05-06 19:24:21 -06:00
d1072d8a81 setup ci 2025-05-06 19:22:25 -06:00
6346b28581 v1 ready for publish 2025-05-06 17:53:17 -06:00
03a1e3ed21 updates checkCode to new framework 2025-05-02 01:11:15 -06:00
26b7089cc2 adds flag coalescing to argparser
adds handling of inlined args to call tools
fixes terminal layout clearing troubles
2025-04-30 03:06:19 -06:00
65f0b4e0b7 custom lint rule for no logfile calls
initial package setup
2025-04-30 01:29:18 -06:00
9535222fb7 improves block functionality
adds cli compatible prompts/logs
adds logfile function for debug
adds multiselect support
new fieldRename
adds listFieldNames
2025-04-30 01:17:45 -06:00
32 changed files with 2339 additions and 321 deletions

View File

@@ -0,0 +1,48 @@
name: Create Version Tag
on:
push:
branches:
- main
- "prerelease-*"
jobs:
version-check:
runs-on: ubuntu-latest
outputs:
tag_created: ${{ steps.tag.outputs.tag_created }}
tag_name: ${{ steps.tag.outputs.tag_name }}
steps:
- uses: actions/checkout@v4
- name: Run version check
id: tag
uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/version-check@v1
build-release:
if: needs.version-check.outputs.tag_created == 'true'
needs: version-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and release binaries
uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/deno-release@main
with:
entrypoint: main.ts
compile-flags: "--allow-read --allow-write --allow-env --allow-net --include asciiart.txt"
env:
GITEA_TOKEN: ${{ secrets.GIT_PAT }}
publish-release:
needs: build-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: 2.3.1
- name: Publish to JSR
run: deno publish --token ${{ secrets.JSR_PAT }}

7
.gitignore vendored
View File

@@ -1,3 +1,8 @@
*.exe
.env
.env
log.txt
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
present, excluding signature fields.
A collection of tools for working with PDF forms.
## 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
@@ -11,7 +20,8 @@ Deno >=2.2 (not required if downloading .exe)
### 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
@@ -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
> it to the PATH
### Precompiled
Download the latest release from the
[releases page](https://git.cyborggrizzly.com/BearMetal/pdf-tools/releases)
## Usage
`checkfields <path to PDF> <comma-separated list of paths to CS class files>`
-OR- `checkfields` and follow prompts.
`pdf-tools <tool> <args>` -> `<tool>` is one of the following
### 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).

323
cli/InputManager.ts Normal file
View File

@@ -0,0 +1,323 @@
interface EventMap {
keypress: CLIKeypressEvent;
char: CLICharEvent;
activate: Event;
deactivate: Event;
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;
}
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 });
}
}
type bounds = {
top?: number;
left?: number;
right?: number;
bottom?: number;
boundMode?: "relative" | "absolute";
};
export class InputManager extends ManagerEventTarget {
private static instance = new InputManager();
private active = false;
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++;
this.moveCursor(-1, 0);
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];
switch (code) {
case 65: // Up
this.moveCursor(0, -1);
this.dispatchEvent(new Event("arrow-up"));
break;
case 66: // Down
this.moveCursor(0, 1);
this.dispatchEvent(new Event("arrow-down"));
break;
case 67: // Right
this.moveCursor(1, 0);
this.dispatchEvent(new Event("arrow-right"));
break;
case 68: // Left
this.moveCursor(-1, 0);
this.dispatchEvent(new Event("arrow-left"));
break;
case 51:
if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete"));
i += 4;
continue;
}
break;
}
i += 3;
continue;
}
// Printable ASCII
if (byte >= 32 && byte <= 126) {
this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
);
this.moveCursor(1, 0);
i++;
continue;
}
// Unknown
this.dispatchEvent(
new CLIKeypressEvent({ key: byte, sequence: buf.slice(i, i + 1) }),
);
i++;
}
}
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,3 +1,6 @@
import { Cursor } from "./cursor.ts";
import { InputManager } from "./InputManager.ts";
export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
private static ALT_BUFFER_DISABLE = "\x1b[?1049l";
@@ -12,10 +15,16 @@ export class TerminalLayout {
constructor() {
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_ENABLE + TerminalLayout.CURSOR_HIDE,
TerminalLayout.ALT_BUFFER_ENABLE,
),
);
Cursor.hide();
this.height = Deno.consoleSize().rows;
Deno.addSignalListener("SIGINT", () => {
this.clearAll();
// Deno.exit(0);
});
}
register(name: string, block: TerminalBlock, fixedHeight?: number) {
@@ -36,7 +45,9 @@ export class TerminalLayout {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(
() => this.renderLayout(),
() => {
this.renderLayout();
},
this.debounceDelay,
);
}
@@ -68,17 +79,28 @@ export class TerminalLayout {
block.renderInternal(usedLines + 1);
usedLines += lines.length;
}
for (const name of this.layoutOrder) {
const block = this.blocks[name];
block.runPostRenderAction?.();
}
}
clearAll() {
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_DISABLE + TerminalLayout.CURSOR_SHOW,
),
);
for (const name of this.layoutOrder) {
this.blocks[name].clear();
}
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_DISABLE,
),
);
Cursor.show();
}
clear() {
for (let i = 0; i < this.height; i++) {
Deno.stdout.writeSync(new TextEncoder().encode("\x1b[2K\x1b[1E"));
}
}
get availableHeight() {
@@ -96,7 +118,16 @@ export class TerminalBlock {
private renderHeight: number = 0;
private lastRenderRow = 1;
constructor(private prepend: string = "") {}
private lastRendered: string[] = [];
private preserveHistory = false;
constructor(private prepend: string = "") {
}
setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory;
}
setLayout(layout: TerminalLayout) {
this.layout = layout;
@@ -117,6 +148,55 @@ export class TerminalBlock {
}
}
wrapLines(maxWidth: number): string[] {
const wrapped: string[] = [];
const inputManager = InputManager.getInstance();
const cursor = inputManager.getCursor();
const bounds = inputManager.getBounds();
const blockStart = this.lastRenderRow;
let visualRow = blockStart;
let maxCursorRow = cursor.row;
for (const line of this.lines) {
const chunks: string[] = [];
for (let start = 0; start < line.length; start += maxWidth) {
const chunk = line.slice(start, start + maxWidth);
chunks.push(chunk);
wrapped.push(this.prepend + chunk);
}
const visualLines = chunks.length;
const visualEnd = visualRow + visualLines - 1;
// Check if the cursor is within this wrapped lines visual range
if (cursor.row >= visualRow && cursor.row <= visualEnd) {
maxCursorRow = visualEnd; // this becomes the new bottom bound
}
visualRow = visualEnd + 1;
}
if (maxCursorRow !== cursor.row) {
inputManager.setBounds({
...bounds,
bottom: maxCursorRow - blockStart,
});
}
return wrapped;
}
append(lines: string[]) {
this.lines.push(...lines);
this.scrollTo(this.lines.length - 1);
if (this.layout) {
this.layout.requestRender();
}
}
scrollTo(offset: number) {
this.scrollOffset = Math.max(0, Math.min(offset, this.lines.length - 1));
if (this.layout) {
@@ -139,20 +219,20 @@ export class TerminalBlock {
}
getRenderedLines(maxHeight: number): string[] {
return this.lines.slice(this.scrollOffset, this.scrollOffset + maxHeight);
const width = Deno.consoleSize().columns - this.prepend.length;
const wrapped = this.wrapLines(width);
return wrapped.slice(this.scrollOffset, this.scrollOffset + maxHeight);
}
getStartRow(): number {
return this.lastRenderRow;
}
getEndRow(): number {
return this.lastRenderRow + this.renderedLineCount - 1;
}
setRenderLines(lines: string[]) {
this.renderLines = lines;
this.renderedLineCount = lines.reduce(
(count, line) =>
count +
Math.ceil(
(this.prepend.length + line.length) /
(Deno.consoleSize().columns || 80),
),
0,
);
}
setRenderHeight(height: number) {
@@ -164,17 +244,39 @@ export class TerminalBlock {
}
renderInternal(startRow?: number) {
this.lastRenderRow = startRow ?? this.lastRenderRow;
this.clear();
let output = this.renderLines.map((line) => `${this.prepend}${line}\x1b[K`)
.join("\n");
if (startRow !== undefined) {
const moveCursor = `\x1b[${startRow};1H`;
output = moveCursor + output;
const outputLines: string[] = [];
for (let i = 0; i < this.renderLines.length; i++) {
const line = `${this.prepend}${this.renderLines[i]}`;
const previous = this.lastRendered[i];
if (line !== previous) {
const moveToLine = `\x1b[${(startRow ?? this.lastRenderRow) + i};1H`;
outputLines.push(moveToLine + line + "\x1b[K");
}
}
Deno.stdout.writeSync(
new TextEncoder().encode(output),
);
if (this.lastRendered.length > this.renderLines.length) {
const baseRow = startRow ?? this.lastRenderRow;
for (let i = this.renderLines.length; i < this.lastRendered.length; i++) {
const moveToLine = `\x1b[${baseRow + i};1H\x1b[2K`;
Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
}
}
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() {
@@ -184,7 +286,11 @@ export class TerminalBlock {
for (let i = 0; i < this.renderedLineCount; i++) {
Deno.stdout.writeSync(new TextEncoder().encode(`\x1b[2K\x1b[1E`));
}
this.renderedLineCount = 0;
}
clearAll() {
this.clear();
this.lines = [];
}
setFixedHeight(height: number) {
@@ -198,6 +304,26 @@ export class TerminalBlock {
getFixedHeight(): number {
return this.fixedHeight ?? 0;
}
requestCursorAt(
lineOffsetFromStart = 0,
col = 0,
): [row: number, col: number] {
return [this.lastRenderRow + lineOffsetFromStart, col];
}
private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action;
}
runPostRenderAction() {
const im = InputManager.getInstance();
im.moveCursor(0, 0);
if (this._postRenderAction) {
this._postRenderAction.call(this);
this._postRenderAction = undefined;
}
}
get lineCount() {
return this.renderLines.length;

View File

@@ -1,5 +1,6 @@
export class ArgParser {
export class ArgParser<T extends Record<string, string[]>> {
private args: string[];
private flags: Map<keyof T, boolean> = new Map();
constructor(args: string[]) {
this.args = args;
@@ -11,7 +12,22 @@ export class ArgParser {
return this.args[index + 1];
}
get flags() {
setFlagDefs(flagDefs: T) {
for (const [flag, defs] of Object.entries(flagDefs)) {
for (const def of defs) {
if (this.argFlags.includes(def)) {
this.flags.set(flag, true);
}
}
}
return this;
}
getFlag(flag: keyof T) {
return this.flags.get(flag);
}
get argFlags() {
return this.args.filter((arg) => arg.startsWith("-"));
}
@@ -26,6 +42,9 @@ export class ArgParser {
get task() {
return this.nonFlags[0];
}
get taskArgs() {
return this.nonFlags.slice(1);
}
static parse(args: string[]) {
return new ArgParser(args);

29
cli/cursor.ts Normal file
View File

@@ -0,0 +1,29 @@
export class Cursor {
private static visible = true;
static show() {
this.visible = true;
Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?25h"));
}
static hide() {
this.visible = false;
Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?25l"));
}
static restoreVisibility() {
if (this.visible) {
this.show();
} else {
this.hide();
}
}
static savePosition() {
Deno.stdout.writeSync(new TextEncoder().encode("\x1b7"));
}
static restorePosition() {
Deno.stdout.writeSync(new TextEncoder().encode("\x1b8"));
}
}

29
cli/forceArgs.ts Normal file
View File

@@ -0,0 +1,29 @@
import { cliPrompt } from "./prompts.ts";
import type { TerminalBlock } from "./TerminalLayout.ts";
type prompt = [string, (v?: string) => boolean | undefined] | string;
export async function forceArgs(
args: string[],
prompts: prompt[],
block?: TerminalBlock,
) {
const newArgs: string[] = [];
for (const [i, arg] of args.entries()) {
if (typeof prompts[i] === "string") {
let val = arg;
while (!val) {
val = await cliPrompt(prompts[i], block) || "";
}
newArgs.push(val);
} else {
const [promptText, validation] = prompts[i];
let val = arg;
while (!validation(val)) {
val = await cliPrompt(promptText, block) || "";
}
newArgs.push(val);
}
}
return newArgs;
}

View File

@@ -1,85 +1,167 @@
import { getAsciiArt } from "../util/asciiArt.ts";
import { toCase } from "util/caseManagement.ts";
import { ArgParser } from "./argParser.ts";
import { colorize } from "./colorize.ts";
import { colorize } from "./style.ts";
import { selectMenuInteractive } from "./selectMenu.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { cliAlert, cliLog } from "./prompts.ts";
import type { ITool } from "../types.ts";
import { InputManager } from "./InputManager.ts";
// Register tools here (filename, no extension)
const toolRegistry: [string, Promise<{ default: ITool }>][] = [
["checkCode", import("../tools/checkCode.ts")],
["fieldRename", import("../tools/fieldRename.ts")],
["listFormFields", import("../tools/listFormFields.ts")],
["deleteFields", import("../tools/deleteFields.ts")],
["fieldVisibility", import("../tools/fieldVisibility.ts")],
];
export class PdfToolsCli {
private tools: Map<string, ITool> = new Map();
private terminalLayout = new TerminalLayout();
closeMessage?: string;
private args = ArgParser.parse(Deno.args);
private args = ArgParser.parse(Deno.args).setFlagDefs({
help: ["-h", "--help"],
banana: ["-b", "--banana"],
});
async importTools(tools?: string) {
tools = tools?.replace(/\/$/, "").replace(/^\.?\//, "") || "tools";
for (const toolfile of Deno.readDirSync(tools)) {
if (toolfile.isFile) {
const tool = await import(
Deno.cwd() + "/" + tools + "/" + toolfile.name
);
if (tool.default) {
async importTools() {
for (const [name, toolfile] of toolRegistry) {
const t = await toolfile;
try {
if (t.default) {
this.tools.set(
toCase(toolfile.name.replace(".ts", ""), "title"),
tool.default,
toCase(name, "title"),
t.default,
);
}
} catch (e) {
cliLog(e + "\n", this.terminalLayout.getBlock("body"));
}
}
}
private async banana() {
const asciiArt = await getAsciiArt("banana");
console.log(colorize(asciiArt, "yellow"));
this.closeMessage = colorize(asciiArt, "yellow");
}
private help() {
console.log("BearMetal PDF CLI");
private async help() {
this.terminalLayout.clear();
this.ensmallenHeader("Help");
const bodyBlock = this.terminalLayout.getBlock("body");
bodyBlock.clearAll();
await cliAlert("BearMetal PDF CLI\n", bodyBlock);
await this.embiggenHeader();
}
public async run() {
const im = InputManager.getInstance();
im.activate();
im.addEventListener("exit", () => {
this.closeMessage = "Exiting...";
this.cleanup();
});
try {
const lines: string[] = [];
for (const t of ["bearmetal:porple", "pdftools:cyan"]) {
const [name, color] = t.split(":");
const asciiArt = await getAsciiArt(name);
lines.push(...colorize(asciiArt, color).split("\n"));
}
await this.importTools();
const titleBlock = new TerminalBlock();
this.terminalLayout.register("title", titleBlock);
const bodyBlock = new TerminalBlock();
this.terminalLayout.register("body", bodyBlock);
titleBlock.setFixedHeight(lines.length);
titleBlock.setLines(lines);
if (Deno.args.length === 0) {
// console.log(
// colorize("No tool specified. Importing all tools...", "gray"),
// );
await this.importTools();
if (this.args.getFlag("banana")) {
titleBlock.setFixedHeight(0);
await this.banana();
return;
}
if (this.args.getFlag("help") && !this.args.task) {
await this.help();
return;
} else if (this.args.nonFlags.length === 0 || !this.args.task) {
this.embiggenHeader();
await this.toolMenu();
} else {
const task = this.args.task;
await this.runTool(toCase(task, "title"));
}
this.toolMenu();
} finally {
this.terminalLayout.clearAll();
this.cleanup();
Deno.exit(0);
}
}
private cleanup() {
this.terminalLayout.clearAll();
InputManager.getInstance().deactivate();
Deno.stdin.setRaw(false);
if (this.closeMessage) console.log(this.closeMessage);
}
private async toolMenu() {
const tools = this.tools.keys().toArray();
const bodyBlock = this.terminalLayout.getBlock("body");
const selected = await selectMenuInteractive("Choose a tool", tools, {
terminalBlock: bodyBlock,
});
bodyBlock.clear();
bodyBlock.clearAll();
bodyBlock.setPreserveHistory(false);
const selected = await selectMenuInteractive(
"Choose a tool",
tools.concat(["Help", "Exit"]),
{
terminalBlock: bodyBlock,
},
);
if (!selected) return;
if (selected === "Exit") {
return;
}
await this.runTool(selected);
this.toolMenu();
await this.toolMenu();
}
private async runTool(toolName: string) {
if (toolName === "Help") {
return await this.help();
}
const tool = this.tools.get(toolName);
if (tool) {
await tool.run();
await tool.done?.();
this.ensmallenHeader(tool.name + " - " + tool.description);
const bodyBlock = this.terminalLayout.getBlock("body");
bodyBlock.clearAll();
tool.setBlock?.(bodyBlock);
if (this.args.getFlag("help")) {
await tool.help?.();
} else {
await tool.run(...this.args.taskArgs);
await tool.done?.();
}
await this.embiggenHeader();
} else {
this.closeMessage = "No tool found for " + toolName;
}
}
private ensmallenHeader(subtitle: string) {
this.terminalLayout.clear();
const titleBlock = this.terminalLayout.getBlock("title");
titleBlock.clear();
titleBlock.setFixedHeight(3);
titleBlock.setLines([
colorize("BearMetal PDF Tools", "porple"),
colorize(subtitle, "gray"),
"-=".repeat(Deno.consoleSize().columns / 2),
]);
}
private async embiggenHeader() {
const titleBlock = this.terminalLayout.getBlock("title");
titleBlock.clear();
const lines: string[] = [];
for (const t of ["bearmetal:porple", "pdftools:cyan"]) {
const [name, color] = t.split(":");
const asciiArt = await getAsciiArt(name);
lines.push(...colorize(asciiArt, color).split("\n"));
}
titleBlock.setFixedHeight(lines.length);
titleBlock.setLines(lines);
}
}

216
cli/prompts.ts Normal file
View File

@@ -0,0 +1,216 @@
// deno-lint-disable-must-await-calls
import { Cursor } from "./cursor.ts";
import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
export async function cliPrompt(
message: string,
block?: TerminalBlock,
): Promise<string> {
const encoder = new TextEncoder();
const input: string[] = [];
let cursorPos = 0;
Cursor.show();
const im = InputManager.getInstance();
im.activate();
const render = () => {
const line = message + " " + input.join("");
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) {
block.setLines([line]);
} else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo));
}
};
render();
const cPos = block?.requestCursorAt(0, message.length + 1);
if (cPos) {
const [row, column] = cPos;
im.setCursor(row, column);
im.setBounds({ top: row, left: column, right: column, bottom: row });
}
const exit = () => {
im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace);
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;
cursorPos = im.getRelativeCursor();
input.splice(cursorPos, 0, ke.char);
im.updateBounds({ right: input.length + message.length + 1 });
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;
}
}
function onKey(e: CLICharEvent) {
const ke = e.detail;
const char = String.fromCharCode(ke.key);
if (isValidInput(char)) {
inpout += char;
} else {
e.stopImmediatePropagation();
}
}
im.addEventListener("char", onKey);
const value = await cliPrompt(message + " (y/n)", block).then((v) =>
v.charAt(0).toLowerCase() === "y"
);
im.removeEventListener("char", onKey);
return value;
}
export async function cliAlert(message: string, block?: TerminalBlock) {
const im = InputManager.getInstance();
const onKey = (e: CLICharEvent) => {
e.stopImmediatePropagation();
};
im.addEventListener("char", onKey);
await cliPrompt(
message + colorize(" Press Enter to continue", "gray"),
block,
);
im.removeEventListener("char", onKey);
}
export function cliLog(
message: string | object | Array<unknown>,
block?: TerminalBlock,
) {
if (!block) {
console.log(message);
} else {
if (typeof message === "object") message = Deno.inspect(message);
block.setLines(message.split("\n"));
}
}
if (import.meta.main) {
Cursor.hide();
const layout = new TerminalLayout();
const title = new TerminalBlock();
const block = new TerminalBlock();
const footer = new TerminalBlock();
block.setPreserveHistory(true);
// ScrollManager.enable(block);
title.setLines(["Hello, World!"]);
title.setFixedHeight(1);
footer.setLines(["Press Ctrl+C to exit"]);
footer.setFixedHeight(1);
layout.register("title", title);
layout.register("block", block);
layout.register("footer", footer);
InputManager.addEventListener("exit", () => {
layout.clearAll();
// console.clear();
Deno.exit(0);
});
Deno.addSignalListener("SIGINT", () => {
layout.clearAll();
// console.clear();
// Deno.exit(0);
});
const name = await cliPrompt("Enter your name:", block);
cliLog(`Hello, ${name}!`, block);
const single = await cliConfirm("Are you single?", block);
cliLog(single ? "Do you want to go out with me?" : "Okay", block);
// ScrollManager.enable(block);
const loopingConvo = [
"No response?",
"I guess that's okay",
"Maybe I'll see you next week?",
"Wow, really not going to say anything to me?",
"Well, if that's how you feel",
];
let convo = 0;
setInterval(() => {
cliLog(loopingConvo[convo % loopingConvo.length], block);
convo++;
}, 2000);
// setTimeout(async () => {
// await cliAlert("Well, if that's that...", block);
// Deno.exit(0);
// }, 10000);
}

View File

@@ -1,9 +1,13 @@
import { colorize } from "./colorize.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 { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
interface ISelectMenuConfig {
multiSelect?: boolean;
terminalBlock?: TerminalBlock;
initialSelection?: number;
initialSelections?: number[];
}
export function selectMenu(items: string[]) {
@@ -21,18 +25,11 @@ export async function selectMenuInteractive(
Deno.stdin.setRaw(true);
let selected = 0;
if (config?.multiSelect) {
console.warn("Multi-select not implemented yet");
return null;
}
const terminalBlock = config?.terminalBlock || new TerminalBlock();
if (!config?.terminalBlock) {
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
console.log(terminalBlock.getRenderHeight());
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
@@ -66,64 +63,226 @@ export async function selectMenuInteractive(
let inputBuffer = "";
// Function to handle input
async function handleInput() {
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 im = InputManager.getInstance();
im.activate();
const [a, b, c] = buf;
const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
if (a === 3) {
Deno.stdin.setRaw(false);
console.log("\nInterrupted\n");
Deno.exit(130);
const onDown = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected + 1) % options.length;
renderMenu();
};
const onKey = (e: CLICharEvent) => {
e.stopImmediatePropagation();
const ke = e.detail;
const char = String.fromCharCode(ke.key);
inputBuffer += char;
};
const onBackspace = (e: Event) => {
e.stopImmediatePropagation();
inputBuffer = inputBuffer.slice(0, -1);
};
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();
if (inputBuffer) {
const parsed = parseInt(inputBuffer);
if (!isNaN(parsed)) {
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);
im.removeEventListener("escape", onEscape);
resolve?.(options[selected]);
};
if (a === 13) { // Enter key
if (inputBuffer) {
const parsed = parseInt(inputBuffer);
if (!isNaN(parsed)) {
selected = parsed - 1;
}
inputBuffer = "";
}
break;
} else if (a === 27 && b === 91) { // Arrow keys
inputBuffer = "";
if (c === 65) { // Up
selected = (selected - 1 + options.length) % options.length;
} else if (c === 66) { // Down
selected = (selected + 1) % options.length;
}
} else if (a >= 48 && a <= 57) {
inputBuffer += String.fromCharCode(a);
} else if (a === 8) {
inputBuffer = inputBuffer.slice(0, -1);
renderMenu();
const final = await new Promise<string | null>((res) => {
resolve = res;
im.addEventListener("char", onKey);
im.addEventListener("backspace", onBackspace);
im.addEventListener("enter", onEnter);
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("escape", onEscape);
});
// terminalBlock.setLines(["Selected: " + final], range);
return final;
}
export async function multiSelectMenuInteractive(
q: string,
options: (string | [string, callback])[],
config?: ISelectMenuConfig & { allOption?: boolean },
): Promise<string[] | null> {
Deno.stdin.setRaw(true);
let selected = 0;
let selectedOptions: number[] = config?.initialSelections || [];
if (config?.allOption) {
options.unshift("Select All");
}
const rawValues = options.map((i) => typeof i === "string" ? i : i[0]);
if (rawValues.length !== options.length) {
throw new Error("Duplicate options in multi-select menu");
}
const terminalBlock = config?.terminalBlock || new TerminalBlock();
if (!config?.terminalBlock) {
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
const checkSelectAll = () => {
if (selectedOptions.includes(0)) {
selectedOptions = [];
} else {
selectedOptions = Array.from(options).map((_, i) => i);
}
};
const validateSelectAll = () => {
const allPresent = selectedOptions.length == options.length;
if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0);
};
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
const maxHeight = Math.min(terminalHeight - 1, options.length);
let startPoint = Math.max(0, selected - Math.floor(maxHeight / 2));
const endPoint = Math.min(options.length, startPoint + maxHeight);
if (endPoint - startPoint < maxHeight) {
startPoint = Math.max(0, options.length - maxHeight);
}
const lines: string[] = [];
lines.push(colorize(q, "green"));
for (let i = startPoint; i < endPoint; i++) {
const option = rawValues[i];
const checkbox = selectedOptions.includes(i)
? colorize("◼", "green")
: "◻";
if (i === selected) {
lines.push(`> ${checkbox} ${colorize(option, "porple")}`);
} else {
lines.push(` ${checkbox} ${option}`);
}
}
terminalBlock.clear();
Deno.stdin.setRaw(false);
return options[selected];
terminalBlock.setLines(lines);
}
return await handleInput();
const im = InputManager.getInstance();
im.activate();
let resolve = null as null | ((value: number[] | null) => void);
const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
const onDown = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected + 1) % options.length;
renderMenu();
};
const onSpace = (e: CLICharEvent) => {
if (e.detail.char !== " ") return;
e.stopImmediatePropagation();
if (config?.allOption && selected === 0) {
checkSelectAll();
} else if (selectedOptions.includes(selected)) {
selectedOptions = selectedOptions.filter((i) => i !== selected);
} else {
selectedOptions.push(selected);
}
validateSelectAll();
renderMenu();
};
const onEscape = () => {
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(null);
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(selectedOptions);
};
renderMenu();
const selections = await new Promise<number[] | null>((res) => {
resolve = res;
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter);
im.addEventListener("escape", onEscape);
});
if (!selections) return null;
for (const optionI of selections) {
const option = options[optionI];
if (Array.isArray(option)) {
await option[1](option[0]);
}
}
const final = selectedOptions.map((i) => rawValues[i]);
terminalBlock.setLines(["Selected: " + final.join(", ")]);
return final;
}
if (import.meta.main) {
// const layout = new TerminalLayout();
// const block = new TerminalBlock();
// const titleBlock = new TerminalBlock();
// const postBlock = new TerminalBlock();
// titleBlock.setLines(["An incredible fruit menu!"]);
// postBlock.setLines(["I'm here too!"]);
// titleBlock.setFixedHeight(1);
// postBlock.setFixedHeight(1);
// layout.register("title", titleBlock);
// layout.register("block", block);
// layout.register("post", postBlock);
const layout = new TerminalLayout();
const block = new TerminalBlock();
const titleBlock = new TerminalBlock();
const postBlock = new TerminalBlock();
InputManager.addEventListener("exit", () => layout.clearAll());
titleBlock.setLines(["An incredible fruit menu!"]);
postBlock.setLines(["I'm here too!"]);
titleBlock.setFixedHeight(1);
postBlock.setFixedHeight(1);
layout.register("title", titleBlock);
layout.register("block", block);
// const val = await selectMenuInteractive("choose a fruit", [
// "apple",
@@ -154,7 +313,7 @@ if (import.meta.main) {
// layout.clearAll();
// console.log(val);
const val = await selectMenuInteractive("choose a fruit", [
const val = await multiSelectMenuInteractive("choose a fruit", [
"apple",
"banana",
"cherry",
@@ -181,6 +340,8 @@ if (import.meta.main) {
"ximenia",
"yuzu",
"zucchini",
]);
console.log(val);
], { terminalBlock: block, allOption: true });
cliLog(val || "No value", block);
// Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
}

View File

@@ -1,15 +1,35 @@
{
"name": "@bearmetal/pdf-tools",
"version": "1.0.8-p",
"license": "GPL 3.0",
"tasks": {
"dev": "deno run -A --env-file=.env --watch main.ts",
"compile": "deno compile -o compare-form-fields.exe --target x86_64-pc-windows-msvc -R ./main.ts",
"dev": "deno run -A main.ts",
"compile": "deno compile -o pdf-tools.exe --target x86_64-pc-windows-msvc --include ./asciiart.txt -A ./main.ts",
"install": "deno install -fgq --import-map ./deno.json -n checkfields -R ./main.ts",
"debug": "deno run -A --env-file=.env --inspect-wait --watch main.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@std/path": "jsr:@std/path@^1.0.9",
"pdf-lib": "npm:pdf-lib@^1.17.1",
"util/": "./util/"
"util/": "./util/",
"@/": "./"
},
"exports": {}
}
"exports": {
".": "./main.ts"
},
"lint": {
"rules": {
"exclude": [
"no-explicit-any"
],
"include": [
"require-await"
]
},
"plugins": [
"./no-log.ts",
"./must_await_cli_prompts.ts"
]
}
}

7
deno.lock generated
View File

@@ -1,8 +1,9 @@
{
"version": "4",
"version": "5",
"specifiers": {
"jsr:@std/assert@1": "1.0.12",
"jsr:@std/internal@^1.0.6": "1.0.6",
"jsr:@std/path@^1.0.9": "1.0.9",
"npm:pdf-lib@^1.17.1": "1.17.1"
},
"jsr": {
@@ -14,6 +15,9 @@
},
"@std/internal@1.0.6": {
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
},
"@std/path@1.0.9": {
"integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
}
},
"npm": {
@@ -48,6 +52,7 @@
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
"jsr:@std/path@^1.0.9",
"npm:pdf-lib@^1.17.1"
]
}

View File

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

45
must_await_cli_prompts.ts Normal file
View File

@@ -0,0 +1,45 @@
const TARGET_FUNCTIONS = new Set(["cliAlert", "cliPrompt", "cliConfirm"]);
const plugin: Deno.lint.Plugin = {
name: "must-await-calls",
rules: {
"must-await-calls": {
create(context) {
return {
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
!TARGET_FUNCTIONS.has(node.callee.name)
) return;
const parent = node.parent;
// Allow `await fetchData()`
if (parent?.type === "AwaitExpression") return;
// Allow `return fetchData()` or `return await fetchData()`
if (parent?.type === "ReturnStatement") return;
// Allow `fetchData().then(...)`
if (
parent?.type === "MemberExpression" &&
parent.property.type === "Identifier" &&
parent.property.name === "then"
) return;
context.report({
node,
message:
`Call to "${node.callee.name}" must be awaited, returned, or .then-chained.`,
fix(fixer) {
return fixer.insertTextBefore(node, "await ");
},
});
},
};
},
},
},
};
export default plugin;

26
no-log.ts Normal file
View File

@@ -0,0 +1,26 @@
const plugin: Deno.lint.Plugin = {
name: "no-log",
rules: {
"no-log": {
create(context) {
return {
// Identifier(node) {
// if (node.name === "log") {
// context.report({
// node,
// message: "Do not use log",
// });
// }
// },
'ExpressionStatement > CallExpression[callee.name="log"]'(node) {
context.report({
node,
message: "Clean up log statements",
});
},
};
},
},
},
};
export default plugin;

Binary file not shown.

View File

@@ -0,0 +1,7 @@
const thing: string = "";
switch (thing) {
case "Text1":
break;
default:
break;
}

View File

@@ -1,58 +1,75 @@
import { PDFDocument } from "pdf-lib";
import { forceArgs } from "../cli/forceArgs.ts";
import { cliAlert, cliLog } from "../cli/prompts.ts";
import { colorize } from "../cli/style.ts";
import type { TerminalBlock } from "../cli/TerminalLayout.ts";
import type { ITool } from "../types.ts";
import { loadPdfForm } from "../util/saveLoadPdf.ts";
import { callWithArgPrompt } from "util/call.ts";
export async function checkFile(pdfPath: string, csPath: string) {
while (!pdfPath || !pdfPath.endsWith(".pdf")) {
pdfPath = prompt("Please provide path to PDF file:") || "";
}
while (!csPath || !csPath.endsWith(".cs")) {
csPath = prompt("Please provide path to CS class file:") || "";
}
const form = await loadPdfForm(pdfPath);
const fields = form.getFields();
const csFiles = await Promise.all(
csPath.split(",").map((c) => Deno.readTextFile(c.trim())),
);
const fieldNames: string[] = fields.map((f) => f.getName())
.filter((f) => {
const rx = new RegExp(
`(?<!//\s?)case ?"${f.replace(/\[\d\]/, "\\[\\?\\]")}"`,
);
return !csFiles.some((c) => rx.test(c));
})
.filter((f) => !f.toLowerCase().includes("signature"));
if (fieldNames.length) {
console.log(
"%cThe following field names are not present in the CS code",
"color: red",
);
console.log(fieldNames);
alert("Your princess is in another castle...");
} else {
console.log("%cAll form fields present", "color: lime");
alert("Ok!");
function getCaseSyntaxPatternByFileExtension(
extenstion: string,
field: string,
) {
switch (extenstion.trim().toLowerCase().replace(".", "")) {
case "cs":
case "js":
case "ts":
default:
return `(?<!//\s?)case ?"${field.replace(/\[\d\]/, `\\[\\?|\\d+\\]`)}"`;
}
}
class CheckCode implements ITool {
name = "checkcode";
description = "Checks if form fields are present in CS code";
help() {
console.log("Usage: checkcode <pdfPath> <csPath>");
description = "Checks if form fields are present in a given code file";
private block?: TerminalBlock;
setBlock(block: TerminalBlock) {
this.block = block;
this.block.setPreserveHistory(true);
}
async run(...args: string[]) {
await callWithArgPrompt(checkFile, [
["Please provide path to PDF file:", (p) => !!p && p.endsWith(".pdf")],
[
"Please provide path to CS file (comma separated for multiple):",
(p) => !!p && p.endsWith(".cs"),
],
], args);
async help() {
cliLog("Usage: checkcode <pdfPath> <csPath>", this.block);
await cliAlert("", this.block);
}
async run(pdfPath: string, codePaths: string) {
[pdfPath, codePaths] = await forceArgs([pdfPath, codePaths], [
"Please provide path to PDF file:",
"Please provide path(s) to code file(s) (comma separated for multiple):",
], this.block);
const form = await loadPdfForm(pdfPath);
const fields = form.getFields();
const codeFiles: [string, string][] = codePaths.split(",").map((
c,
) => [c, Deno.readTextFileSync(c.trim())]);
const fieldNames: string[] = fields.map((f) => f.getName())
.filter((f) => !f.toLowerCase().includes("signature"));
let unfound = fieldNames.slice();
for (const [path, content] of codeFiles) {
unfound = unfound.filter((f) => {
const rx = new RegExp(
getCaseSyntaxPatternByFileExtension(path.split(".").at(-1) ?? "", f),
);
return rx.test(content);
});
}
if (unfound.length) {
cliLog(
colorize(
"The following field names are not present in the CS code",
"red",
),
this.block,
);
cliLog(unfound, this.block);
await cliAlert("Your princess is in another castle...", this.block);
} else {
cliLog(colorize("All form fields present", "green"), this.block);
await cliAlert("Ok!", this.block);
}
}
}

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,109 +1,550 @@
import {
PDFAcroField,
PDFHexString,
type PDFAcroField,
PDFArray,
PDFCheckBox,
type PDFDocument,
type PDFField,
PDFName,
PDFNumber,
PDFRadioGroup,
type PDFRef,
PDFString,
toHexString,
PDFTextField,
type PDFWidgetAnnotation,
} from "pdf-lib";
import { loadPdfForm, savePdf } from "util/saveLoadPdf.ts";
import { PDFDocument } from "pdf-lib";
import { call, callWithArgPrompt } from "util/call.ts";
import { loadPdf, savePdf } from "util/saveLoadPdf.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import { forceArgs } from "../cli/forceArgs.ts";
import { colorize } from "../cli/style.ts";
import { cliAlert, cliLog, cliPrompt } from "../cli/prompts.ts";
import { multiSelectMenuInteractive } from "../cli/selectMenu.ts";
import type { callback, ITool } from "../types.ts";
import { toCase } from "util/caseManagement.ts";
// const thing = PDFAcroField.prototype.getFullyQualifiedName;
// PDFAcroField.prototype.getFullyQualifiedName = function () {
// const name = thing.call(this)
// // if (name?.includes('langauge'))
// console.log(name)
// return name;
// }
// const thing = PDFHexString.prototype.copyBytesInto
// PDFHexString.prototype.copyBytesInto = function (buffer: Uint8Array, offset: number) {
// console.log((this as any).value)
// const result = thing.call(this, buffer, offset)
// return result;
// }
async function renameFields(
path: string,
pattern: string | RegExp,
function applyRename(
field: PDFField,
name: string,
pattern: RegExp,
change: string,
) {
if (typeof pattern === "string") pattern = new RegExp(pattern);
const form = await loadPdfForm(path);
const fields = form.getFields();
let changesMade = false;
for (const field of fields) {
const name = field.getName();
if (pattern.test(name)) {
console.log(name + " %cfound", "color: red");
const segments = name.split(".");
const matchingSegments = segments.filter((s) => pattern.test(s));
let cField: PDFAcroField | undefined = field.acroField;
while (cField) {
if (
cField.getPartialName() &&
matchingSegments.includes(cField.getPartialName()!)
) {
const mName = cField.getPartialName()?.replace(pattern, change);
if (mName) {
changesMade = true;
cField.dict.set(PDFName.of("T"), PDFString.of(mName));
// console.log(cField.getPartialName())
}
}
cField = cField.getParent();
// console.log(cField?.getPartialName())
const segments = name.split(".");
const matchingSegments = segments.filter((s) => pattern.test(s));
let cField: PDFAcroField | undefined = field.acroField;
while (cField) {
if (
cField.getPartialName() &&
matchingSegments.includes(cField.getPartialName()!)
) {
const mName = cField.getPartialName()?.replace(pattern, change);
if (mName) {
cField.dict.set(PDFName.of("T"), PDFString.of(mName));
// console.log(cField.getPartialName())
}
}
cField = cField.getParent();
// console.log(cField?.getPartialName())
}
}
// function applyWidgetRename(
// doc: PDFDocument,
// field: PDFField,
// widget: PDFWidgetAnnotation,
// name: string,
// pattern: RegExp,
// change: string,
// ) {
// if (field.acroField.getWidgets().length > 1) {
// const widgets = field.acroField.getWidgets();
// const widgetIndex = widgets.indexOf(widget);
// widgets.splice(widgetIndex, 1);
// const pdfDocContext = doc.context;
// const originalRef = field.acroField.ref;
// const originalFieldDict = pdfDocContext.lookup(originalRef);
// if (!originalFieldDict) return;
// const newFieldDict = pdfDocContext.obj({
// ...originalFieldDict,
// T: PDFString.of(name.replace(pattern, change)),
// Kids: [getWidgetRef(widget, doc.getPages())],
// });
// const newField = pdfDocContext.register(newFieldDict);
// const acroForm = doc.catalog.lookup(PDFName.of("AcroForm"), PDFDict);
// const fields = acroForm.lookup(PDFName.of("Fields"), PDFArray);
// fields.push(newField);
// }
// }
function findPageForWidget(
doc: PDFDocument,
widget: PDFWidgetAnnotation,
) {
const pages = doc.getPages();
for (const page of pages) {
const annots = page.node.Annots();
if (!annots) continue;
const annotRefs = annots.asArray();
for (const ref of annotRefs) {
const annot = doc.context.lookup(ref);
if (annot === widget.dict) {
return page;
}
console.log(field.getName());
// const newName = name.replace(pattern, change);
// console.log("Change to: %c" + newName, "color: yellow");
// if (confirm('Ok?')) {
// let parent = field.acroField.getParent();
// field.acroField.setPartialName(segments.pop())
// while (parent && segments.length) {
// console.log(parent.getPartialName())
// parent.setPartialName(segments.pop())
// parent = parent.getParent();
// }
// changesMade = true;
// console.log(field.getName())
// // dict.set(PDFName.of("T"), PDFHexString.fromText(newName))
// console.log("%cDone!", "color: lime")
// }
// break;
}
}
if (changesMade) {
savePdf(form.doc, path);
return undefined;
}
function detectFieldType(field: PDFField): string | undefined {
const ft = field.acroField.dict.get(PDFName.of("FT"));
return ft instanceof PDFName ? ft.asString() : undefined;
}
function getFlag(field: PDFField, bit: number): boolean {
const ff = field.acroField.dict.get(PDFName.of("Ff"));
return ff instanceof PDFNumber ? (ff.asNumber() & (1 << bit)) !== 0 : false;
}
function getWidgetRef(
widget: PDFWidgetAnnotation,
doc: PDFDocument,
): PDFRef | undefined {
for (const page of doc.getPages()) {
const annots = page.node.Annots()?.asArray() ?? [];
for (const ref of annots) {
const maybeDict = doc.context.lookup(ref);
if (maybeDict === widget.dict) {
return ref as PDFRef;
}
}
}
return undefined;
}
function applyWidgetRename(
doc: PDFDocument,
field: PDFField,
widget: PDFWidgetAnnotation,
newName: string,
pattern: RegExp,
change: string,
) {
try {
const form = doc.getForm();
const widgets = field.acroField.getWidgets();
if (widgets.length <= 1) return;
const widgetDict = widget.dict;
const widgetIndex = widgets.findIndex((w) => w.dict === widgetDict);
if (widgetIndex === -1) return;
widgets.splice(widgetIndex, 1);
const kids = field.acroField.dict.lookup(PDFName.of("Kids"), PDFArray);
if (kids) {
const updatedKids = kids.asArray().filter((ref) => {
const maybeDict = doc.context.lookup(ref);
return maybeDict !== widget.dict;
});
field.acroField.dict.set(
PDFName.of("Kids"),
doc.context.obj(updatedKids),
);
}
const page = findPageForWidget(doc, widget);
if (!page) throw new Error("Widget page not found");
const rect = widget.getRectangle();
if (!rect) throw new Error("Widget has no rectangle");
const finalName = newName.replace(pattern, change);
// Try to get existing field with the new name
let targetField: PDFField | undefined;
try {
targetField = form.getField(finalName);
} catch {
// Field doesn't exist — that's fine
}
// Compare field types if field exists
if (targetField) {
const sourceType = detectFieldType(field);
const targetType = detectFieldType(targetField);
if (sourceType !== targetType) {
throw new Error(
`Field "${finalName}" already exists with a different type (${targetType} vs ${sourceType})`,
);
}
// ✅ Same type — attach widget to the existing field
// const targetFieldWidgets = targetField.acroField.getWidgets();
const targetKidsArray = targetField.acroField.dict.lookup(
PDFName.of("Kids"),
PDFArray,
);
// Set /Parent on the widget to point to the existing field
widget.dict.set(PDFName.of("Parent"), targetField.acroField.ref);
// Add the widget to the field's /Kids array
const widgetRef = getWidgetRef(widget, doc);
if (!widgetRef) throw new Error("Widget ref not found");
if (targetKidsArray) {
targetKidsArray.push(widgetRef);
} else {
targetField.acroField.dict.set(
PDFName.of("Kids"),
doc.context.obj([widgetRef]),
);
}
// Also ensure widget is attached to a page
const page = findPageForWidget(doc, widget);
if (!page) throw new Error("Widget's page not found");
const pageAnnots = page.node.Annots();
const refs = pageAnnots?.asArray() ?? [];
if (!refs.includes(widgetRef)) {
refs.push(widgetRef);
page.node.set(PDFName.of("Annots"), doc.context.obj(refs));
}
return; // Done
}
removeWidgetFromPage(widget, doc);
const fieldType = detectFieldType(field);
let newField: PDFField;
switch (fieldType) {
case "/Tx": {
const tf = form.createTextField(finalName);
if (field instanceof PDFTextField) {
const val = field.getText();
if (val) tf.setText(val);
}
newField = tf;
break;
}
case "/Btn": {
const isRadio = getFlag(field, 15);
if (isRadio) {
const rf = form.createRadioGroup(finalName);
rf.addOptionToPage(finalName, page, {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
if (field instanceof PDFRadioGroup) {
const selected = field.getSelected();
if (selected) rf.select(selected);
}
return;
} else {
const cb = form.createCheckBox(finalName);
cb.addToPage(page, {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
if (field instanceof PDFCheckBox && field.isChecked()) {
cb.check();
}
return;
}
}
default:
throw new Error(`Unsupported field type: ${fieldType}`);
}
// Attach the new field to the page if necessary
if (
newField instanceof PDFTextField ||
newField instanceof PDFCheckBox
) {
newField.addToPage(page, {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
}
} catch {
// log(e);
}
}
function removeWidgetFromPage(widget: PDFWidgetAnnotation, doc: PDFDocument) {
const pages = doc.getPages();
for (const page of pages) {
const annotsArray = page.node.Annots();
if (!annotsArray) continue;
const refs = annotsArray.asArray();
const newRefs = refs.filter((ref) => {
const maybeDict = doc.context.lookup(ref);
return maybeDict !== widget.dict;
});
// Replace /Annots with updated array
if (newRefs.length === refs.length) continue;
page.node.set(PDFName.of("Annots"), doc.context.obj(newRefs));
}
}
// 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
*
* @description The change string can include the following variables:
*
* - $<int> - capture groups, indexed from 1
* - $<int>i - capture groups, indexed from 1, transforming an integer to an index
* - $<int>s - capture groups, indexed from 1, transforming a string to snake case
* - $<int>c - capture groups, indexed from 1, transforming a string to camel case
* - $<int>l - capture groups, indexed from 1, transforming a string to lower case
* - $<int>u - capture groups, indexed from 1, transforming a string to upper case
* - $<int>t - capture groups, indexed from 1, transforming a string to title case
*/
function evaluateChange(change: string, match: RegExpExecArray, index: number) {
return change.replace(
/\$(\d+)([icslut]?)/g,
(_, i, indexed) => {
switch (indexed) {
case "i":
return (parseInt(match[i])
? (parseInt(match[i]) - 1).toString()
: match[i]);
case "s":
return toCase(match[i], "snake");
case "c":
return toCase(match[i], "camel");
case "t":
return toCase(match[i], "title");
case "l":
return match[i].toLowerCase();
case "u":
return match[i].toUpperCase();
default:
return match[i];
}
},
)
.replace(
/\$I{((\w+,?)+)}/,
(_, offset) => {
const options = offset.split(",");
return options[index % options.length];
},
)
.replace(
/\$I(-?\d+)?/,
(_, offset) =>
(parseInt(offset) ? index + parseInt(offset) : index).toString(),
);
}
class RenameFields implements ITool {
name = "renamefields";
description = "Renames fields in a PDF form";
help() {
console.log("Usage: renamefields <pdfPath> <pattern> <change>");
block: TerminalBlock | undefined;
setBlock(block: TerminalBlock) {
this.block = block;
}
async help(standalone = false) {
await cliAlert(
"Usage: rename-fields <pdfPath> <pattern> <change>\n",
standalone ? undefined : this.block,
);
}
async run(pdfPath: string = "", pattern: string = "", change: string = "") {
await callWithArgPrompt(renameFields, [
["Please provide path to PDF:", (p) => !!p && p.endsWith(".pdf")],
"Please provide search string:",
"Please provide requested change:",
], [pdfPath, pattern, change]);
if (!this.block) {
this.block = new TerminalBlock();
}
this.block.setPreserveHistory(false);
[pdfPath, pattern, change] = await forceArgs(
[pdfPath, pattern, change],
[
[
"Please provide path to PDF (comma separated for multiple):",
(p) => !!p && p.endsWith(".pdf"),
],
"Please provide search string:",
"Please provide requested change:",
],
this.block,
);
const paths = pdfPath.split(",");
for (const pdfPath of paths) {
const patternRegex = new RegExp(pattern);
const pdf = await loadPdf(pdfPath);
const form = pdf.getForm();
const fields = form.getFields().sort((a, b) => {
const aWidgets = a.acroField.getWidgets();
const bWidgets = b.acroField.getWidgets();
const aWidget = aWidgets[0];
const bWidget = bWidgets[0];
const aPage = a.doc.findPageForAnnotationRef(a.acroField.ref);
const bPage = b.doc.findPageForAnnotationRef(b.acroField.ref);
if (aPage && bPage && aPage !== bPage) {
const pages = a.doc.getPages();
const aPageIndex = pages.indexOf(aPage);
const bPageIndex = pages.indexOf(bPage);
if (aPageIndex !== bPageIndex) return aPageIndex - bPageIndex;
}
const aRect = aWidget.Rect()?.asRectangle();
const bRect = bWidget.Rect()?.asRectangle();
if (aRect && bRect) {
const dy = bRect.y - aRect.y;
if (Math.abs(dy) > 5) return dy;
return aRect.x - bRect.x;
}
return a.getName().localeCompare(b.getName());
});
let badFields = 0;
for (const field of fields) {
if (field.acroField.getWidgets().length > 1) {
badFields++;
}
}
badFields && await cliLog(
colorize(
`Warning, ${badFields} fields with shared widgets found`,
"yellow",
),
this.block,
);
const foundUpdates: [string, callback][] = [];
let changesMade = false;
let i = 0;
for (const field of fields) {
const name = field.getName();
const match = patternRegex.exec(name);
if (match) {
foundUpdates.push(
...field.acroField.getWidgets()?.map<[string, callback]>((
widget,
) => {
const toChange = evaluateChange(change, match, i);
const preview = name.replace(
new RegExp(patternRegex),
toChange,
);
i++;
return [
`${colorize(name, "red")} -> ${colorize(preview, "green")}`,
() => {
field.acroField.getWidgets().length > 1
? applyWidgetRename(
pdf,
field,
widget,
name,
new RegExp(patternRegex),
toChange,
)
: applyRename(field, name, patternRegex, toChange);
changesMade = true;
},
];
}),
);
}
}
if (foundUpdates.length) {
await cliLog("Found updates:", this.block);
await multiSelectMenuInteractive(
"Please select an option to apply",
foundUpdates,
{ terminalBlock: this.block },
);
}
if (changesMade) {
const path = await cliPrompt(
"Save to path (or hit enter to keep current):",
this.block,
);
try {
await savePdf(pdf, path || pdfPath);
} catch {
// log(e);
}
} else {
cliLog("No changes made, skipping", this.block);
}
}
}
}
export default new RenameFields();
if (import.meta.main) {
// await call(renameFields)
// while (!path || !path.endsWith('.pdf')) path = prompt("Please provide path to PDF:") || '';
// while (!pattern) pattern = prompt("Please provide search string:") || '';
// while (!change) change = prompt("Please provide requested change:") || '';
await callWithArgPrompt(renameFields, [
["Please provide path to PDF:", (p) => !!p && p.endsWith(".pdf")],
"Please provide search string:",
"Please provide requested change:",
]);
}
// if (import.meta.main) {
// // await call(renameFields)
// // while (!path || !path.endsWith('.pdf')) path = prompt("Please provide path to PDF:") || '';
// // while (!pattern) pattern = prompt("Please provide search string:") || '';
// // while (!change) change = prompt("Please provide requested change:") || '';
// await callWithArgPrompt(renameFields, [
// ["Please provide path to PDF:", (p) => !!p && p.endsWith(".pdf")],
// "Please provide search string:",
// "Please provide requested change:",
// ]);
// }

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();

63
tools/listFormFields.ts Normal file
View File

@@ -0,0 +1,63 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import { loadPdfForm } from "util/saveLoadPdf.ts";
import type { ITool } from "../types.ts";
import { InputManager } from "../cli/InputManager.ts";
export class ListFormFields implements ITool {
name = "listformfields";
description = "Lists fields in a PDF form";
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:",
(p) => !!p && p.endsWith(".pdf"),
]], this.block);
const lines = [pdfPath];
let rLines: string[] = [];
const form = await loadPdfForm(pdfPath);
const fields = form.getFields();
const fieldNames = fields.map((f) => f.getName());
let offset = 0;
const buildRLines = () => {
rLines = fieldNames.slice(offset, this.block!.getRenderHeight());
this.block!.setLines(lines.concat(rLines));
};
buildRLines();
await new Promise<void>((res) => {
const im = InputManager.getInstance();
const up = () => {
if (fieldNames.length < this.block!.getRenderHeight() - 1) return;
offset = Math.max(0, offset - 1);
buildRLines();
};
const down = () => {
if (fieldNames.length < this.block!.getRenderHeight() - 1) return;
offset = Math.min(fieldNames.length, offset + 1);
buildRLines();
};
const enter = () => {
res();
im.removeEventListener("arrow-up", up);
im.removeEventListener("arrow-down", down);
im.removeEventListener("enter", enter);
};
im.addEventListener("arrow-up", up);
im.addEventListener("arrow-down", down);
im.addEventListener("enter", enter);
});
}
setBlock(terminalBlock: TerminalBlock) {
this.block = terminalBlock;
}
}
export default new ListFormFields();

View File

@@ -1,10 +1,13 @@
declare global {
type ToolFunc<T extends unknown[]> = (...args: T) => Promise<void>;
interface ITool {
name: string;
description: string;
run: ToolFunc<any[]>;
help?: () => Promise<void> | void;
done?: () => Promise<void> | void;
}
import type { TerminalBlock } from "./cli/TerminalLayout.ts";
export type ToolFunc<T extends unknown[]> = (...args: T) => Promise<void>;
export interface ITool {
name: string;
description: string;
run: ToolFunc<any[]>;
help?: () => Promise<void> | void;
done?: () => Promise<void> | void;
setBlock?: (block: TerminalBlock) => void;
}
export type callback = (...args: any[]) => any;

View File

@@ -1,30 +1,30 @@
export async function getAsciiArt(art: string) {
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);
import { join } from "@std/path";
while (result !== null) {
const [_, name, artText] = result;
if (name === art) return artText;
result = parserRX.exec(artFileText);
export async function getAsciiArt(art: string) {
try {
const artFilePath =
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;
}
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

@@ -1,3 +1,5 @@
import type { ToolFunc } from "../types.ts";
type transformer = (arg: string) => any;
interface IConfig {
multiTransform?: boolean;

View File

@@ -11,9 +11,45 @@ function lowerToTrainCase(str: string) {
);
}
function lowerToCamelCase(str: string) {
return str.trim().replace(/(?:\s)\w/g, (match) => match.toUpperCase())
.replaceAll(" ", "");
/**
* @param str
* @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) {
@@ -88,10 +124,10 @@ function coerceCaseToLower(str: string, caseType: CaseType) {
case "macro":
case "snake":
case "upper":
return str.replace("_", " ").toLowerCase();
return str.replaceAll("_", " ").toLowerCase();
case "train":
case "kebab":
return str.replace("-", " ").toLowerCase();
return str.replaceAll("-", " ").toLowerCase();
default:
return str.toLowerCase();
}
@@ -124,3 +160,7 @@ export function toCase(str: string, toCase: CaseType) {
return str;
}
}
if (import.meta.main) {
console.log(toCase("SSN", "camel"));
}

18
util/dedent.ts Normal file
View File

@@ -0,0 +1,18 @@
export function dedent(str: string) {
const lines = str.split("\n");
const indent = lines.reduce((count, line) => {
if (line.trim() === "") return count;
const match = line.match(/^(\s*)/);
return match ? Math.min(count, match[1].length) : count;
}, Infinity);
return lines.map((line) => line.slice(indent)).join("\n");
}
if (import.meta.main) {
console.log(dedent(`
Hello, World!
This is a paragraph
that spans multiple lines.
And this is another paragraph.
`));
}

15
util/logfile.ts Normal file
View File

@@ -0,0 +1,15 @@
const logFile = Deno.openSync("./log.txt", {
create: true,
write: true,
read: true,
append: true,
});
logFile.truncateSync(0);
export function log(...message: any) {
if (typeof message === "object") {
message = Deno.inspect(message);
}
logFile.writeSync(new TextEncoder().encode(message + "\n"));
}

View File

@@ -1,4 +1,4 @@
import { PDFDocument } from "pdf-lib";
import { PDFDocument, PDFTextField } from "pdf-lib";
export async function loadPdfForm(path: string) {
const pdfDoc = await loadPdf(path);
@@ -13,7 +13,12 @@ export async function loadPdf(path: string) {
}
export async function savePdf(doc: PDFDocument, path: string) {
const pdfBytes = await doc.save();
doc.getForm().getFields().forEach((field) => {
if (field instanceof PDFTextField) {
field.disableRichFormatting?.();
}
});
const pdfBytes = await doc.save({ updateFieldAppearances: true });
if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return;
await Deno.writeFile(path, pdfBytes);
}