50 Commits

Author SHA1 Message Date
343c36a9f0 Merge pull request 'fix: I really am thick' (#14) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 21s
Create Version Tag / build-release (push) Successful in 1m58s
Create Version Tag / publish-release (push) Successful in 31s
Reviewed-on: #14
2025-05-21 11:56:09 -07:00
123bf51001 fix: I really am thick 2025-05-21 12:55:45 -06:00
252863c813 Merge pull request 'fix: I am stupid and forgot to press enter' (#13) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 23s
Create Version Tag / build-release (push) Successful in 1m53s
Create Version Tag / publish-release (push) Successful in 30s
Reviewed-on: #13
2025-05-21 11:41:45 -07:00
a858ea4b60 fix: I am stupid and forgot to press enter 2025-05-21 12:26:40 -06:00
cca6de1877 Merge pull request 'fix: pasting in prompt no worky' (#12) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 22s
Create Version Tag / build-release (push) Successful in 3m54s
Create Version Tag / publish-release (push) Successful in 36s
Reviewed-on: #12
2025-05-21 11:13:06 -07:00
b43a837c6a fix: pasting in prompt no worky 2025-05-21 12:12:37 -06:00
c0ce69af6f Merge pull request 'fix: arrow keys in prompts now move cursor, also implements delete key' (#11) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 23s
Create Version Tag / build-release (push) Successful in 1m51s
Create Version Tag / publish-release (push) Successful in 31s
Reviewed-on: #11
2025-05-21 10:50:55 -07: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
da80c4690b Merge pull request 'fix: jsr install breaks because of missing asciiart file' (#10) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 21s
Create Version Tag / build-release (push) Successful in 3m52s
Create Version Tag / publish-release (push) Successful in 30s
Reviewed-on: #10
2025-05-20 14:14:03 -07:00
001b90744b fix: jsr install breaks because of missing asciiart file 2025-05-20 15:13:36 -06:00
93df271f79 Merge pull request '1.0.2' (#9) from 1.0.2 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 22s
Create Version Tag / build-release (push) Successful in 2m58s
Create Version Tag / publish-release (push) Successful in 32s
Reviewed-on: #9
2025-05-20 12:46:29 -07: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
28 changed files with 1239 additions and 239 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 }}

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
*.exe
.env
log.txt
log

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).

View File

@@ -1,3 +1,5 @@
import { Cursor } from "./cursor.ts";
export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
private static ALT_BUFFER_DISABLE = "\x1b[?1049l";
@@ -12,10 +14,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 +44,9 @@ export class TerminalLayout {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(
() => this.renderLayout(),
() => {
this.renderLayout();
},
this.debounceDelay,
);
}
@@ -68,17 +78,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,14 +117,26 @@ export class TerminalBlock {
private renderHeight: number = 0;
private lastRenderRow = 1;
private lastRendered: string[] = [];
private preserveHistory = false;
constructor(private prepend: string = "") {}
setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory;
}
setLayout(layout: TerminalLayout) {
this.layout = layout;
}
setLines(lines: string[]) {
this.lines = lines;
setLines(lines: string[], range?: [number, number]) {
if (range && this.preserveHistory) {
this.lines.splice(range[0], range[1], ...lines);
} else {
this.lines = this.preserveHistory ? this.lines.concat(lines) : lines;
}
if (this.scrollOffset > lines.length - 1) {
this.scrollOffset = Math.max(0, lines.length - 1);
}
@@ -115,6 +148,19 @@ export class TerminalBlock {
);
this.renderInternal();
}
range = [
range?.[0] ?? this.lines.length - lines.length,
range ? range[0] + lines.length : this.lines.length,
];
return range;
}
append(lines: string[]) {
this.lines.push(...lines);
this.scrollTo(this.lines.length - 1);
if (this.layout) {
this.layout.requestRender();
}
}
scrollTo(offset: number) {
@@ -144,15 +190,6 @@ export class TerminalBlock {
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 +201,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 +243,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) {
@@ -199,6 +262,17 @@ export class TerminalBlock {
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() {
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] | 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,156 @@
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";
// 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")],
];
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() {
try {
await this.importTools();
const titleBlock = new TerminalBlock();
this.terminalLayout.register("title", titleBlock);
const bodyBlock = new TerminalBlock();
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) {
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"));
}
} finally {
this.cleanup();
}
}
private cleanup() {
this.terminalLayout.clearAll();
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");
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);
await this.toolMenu();
}
private async runTool(toolName: string) {
if (toolName === "Help") {
return await this.help();
}
const tool = this.tools.get(toolName);
if (tool) {
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"));
}
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();
}
this.toolMenu();
} finally {
this.terminalLayout.clearAll();
}
}
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();
if (!selected) return;
await this.runTool(selected);
this.toolMenu();
}
private async runTool(toolName: string) {
const tool = this.tools.get(toolName);
if (tool) {
await tool.run();
await tool.done?.();
}
}
}

187
cli/prompts.ts Normal file
View File

@@ -0,0 +1,187 @@
// deno-lint-disable-must-await-calls
import { log } from "util/logfile.ts";
import { Cursor } from "./cursor.ts";
import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.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 function cliConfirm(message: string, block?: TerminalBlock) {
return cliPrompt(message + " (y/n)", block).then((v) =>
v.toLowerCase() === "y"
);
}
export function cliAlert(message: string, block?: TerminalBlock) {
return cliPrompt(
message + colorize(" Press Enter to continue", "gray"),
block,
).then((v) => {
return v;
});
}
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);
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,11 @@
import { colorize } from "./colorize.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import type { callback } from "../types.ts";
import { colorize } from "./style.ts";
import { TerminalBlock } from "./TerminalLayout.ts";
interface ISelectMenuConfig {
multiSelect?: boolean;
terminalBlock?: TerminalBlock;
initialSelection?: number;
initialSelections?: number[];
}
export function selectMenu(items: string[]) {
@@ -21,18 +23,12 @@ 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());
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
@@ -54,7 +50,7 @@ export async function selectMenuInteractive(
}
}
terminalBlock.setLines(lines);
range = terminalBlock.setLines(lines, range);
}
function numberAndPadding(i: number, prefix?: string) {
@@ -78,7 +74,7 @@ export async function selectMenuInteractive(
if (a === 3) {
Deno.stdin.setRaw(false);
console.log("\nInterrupted\n");
terminalBlock?.["layout"]?.clearAll();
Deno.exit(130);
}
@@ -105,13 +101,112 @@ export async function selectMenuInteractive(
}
}
terminalBlock.clear();
Deno.stdin.setRaw(false);
return options[selected];
}
terminalBlock.setLines(["Selected: " + options[selected]], range);
return await handleInput();
}
export async function multiSelectMenuInteractive(
q: string,
options: string[] | [string, callback][],
config?: ISelectMenuConfig,
): Promise<string[] | null> {
Deno.stdin.setRaw(true);
let selected = 0;
let selectedOptions: number[] = config?.initialSelections || [];
const rawValues = new Set(
options.map((i) => typeof i === "string" ? i : i[0]),
).values().toArray();
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);
}
let range: [number, number] = [terminalBlock.lineCount, 1];
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}`);
}
}
range = terminalBlock.setLines(lines, range);
}
// 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 [a, b, c] = buf;
if (a === 3) {
Deno.stdin.setRaw(false);
terminalBlock?.["layout"]?.clearAll();
Deno.exit(130);
}
if (a === 13) { // Enter key
break;
} else if (a === 27 && b === 91) { // Arrow keys
if (c === 65) { // Up
selected = (selected - 1 + options.length) % options.length;
} else if (c === 66) { // Down
selected = (selected + 1) % options.length;
}
} else if (a === 32) { // Space
Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
if (selectedOptions.includes(selected)) {
selectedOptions = selectedOptions.filter((i) => i !== selected);
} else {
selectedOptions.push(selected);
}
}
}
Deno.stdin.setRaw(false);
return selectedOptions;
}
const selections = await handleInput();
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(", ")], range);
return final;
}
if (import.meta.main) {
// const layout = new TerminalLayout();
// const block = new TerminalBlock();
@@ -154,7 +249,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",
@@ -183,4 +278,6 @@ if (import.meta.main) {
"zucchini",
]);
console.log(val);
// Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
}

View File

@@ -1,15 +1,35 @@
{
"name": "@bearmetal/pdf-tools",
"version": "1.0.7",
"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 --env-file=.env 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,3 +1,4 @@
/// <reference types="./types.ts" />
import { PdfToolsCli } from "./cli/index.ts";
const app = new PdfToolsCli();

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;

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

View File

@@ -1,29 +1,13 @@
import {
PDFAcroField,
PDFHexString,
PDFName,
PDFString,
toHexString,
} from "pdf-lib";
import { loadPdfForm, savePdf } from "util/saveLoadPdf.ts";
import { PDFDocument } from "pdf-lib";
import { call, callWithArgPrompt } from "util/call.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;
// }
import { type PDFAcroField, type PDFField, PDFName, PDFString } from "pdf-lib";
import { loadPdf, loadPdfForm, savePdf } from "util/saveLoadPdf.ts";
import { callWithArgPrompt } from "util/call.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";
async function renameFields(
path: string,
@@ -50,29 +34,10 @@ async function renameFields(
if (mName) {
changesMade = true;
cField.dict.set(PDFName.of("T"), PDFString.of(mName));
// console.log(cField.getPartialName())
}
}
cField = cField.getParent();
// console.log(cField?.getPartialName())
}
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) {
@@ -80,18 +45,151 @@ async function renameFields(
}
}
function applyRename(
field: PDFField,
name: string,
pattern: RegExp,
change: string,
) {
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())
}
}
/***
* 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) {
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];
}
},
);
}
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")],
if (!this.block) {
this.block = new TerminalBlock();
}
this.block.setPreserveHistory(true);
[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:",
], [pdfPath, pattern, 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();
const foundUpdates: [string, callback][] = [];
let changesMade = false;
for (const field of fields) {
const name = field.getName();
const match = patternRegex.exec(name);
if (match) {
const toChange = evaluateChange(change, match);
const preview = name.replace(new RegExp(patternRegex), toChange);
foundUpdates.push([
`${colorize(name, "red")} -> ${colorize(preview, "green")}`,
() => {
applyRename(field, name, patternRegex, toChange);
changesMade = true;
},
]);
}
}
if (foundUpdates.length) {
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,
);
await savePdf(pdf, path || pdfPath);
} else {
cliLog("No changes made, skipping", this.block);
}
}
}
}
export default new RenameFields();

57
tools/listFormFields.ts Normal file
View File

@@ -0,0 +1,57 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { cliAlert } from "../cli/prompts.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import { loadPdfForm } from "util/saveLoadPdf.ts";
import type { ITool } from "../types.ts";
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(true);
[pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF:",
(p) => !!p && p.endsWith(".pdf"),
]], this.block);
const form = await loadPdfForm(pdfPath);
const fields = form.getFields();
const height = this.block.getRenderHeight() - 1;
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 = [];
for (let i = 0; i < height; i++) {
let line = "";
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]);
await cliAlert("", this.block);
}
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 {
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,12 +1,19 @@
import { log } from "./logfile.ts";
import { join } from "@std/path";
export async function getAsciiArt(art: string) {
const artFilePath = Deno.env.get("BEARMETAL_ASCII_PATH") ||
getBearmetalAsciiPath();
if (!artFilePath) return art;
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")) {
if (artFilePath?.startsWith("http")) {
artFileText = await fetch(artFilePath).then((res) => res.text());
} else {
artFileText = await Deno.readTextFile(artFilePath);
artFileText = await Deno.readTextFile(
artFilePath,
);
}
const parserRX = /begin\s+(\w+)\s*\n([\s\S]*?)\s*end\s*/g;
let result = parserRX.exec(artFileText);
@@ -16,15 +23,9 @@ export async function getAsciiArt(art: string) {
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 = JSON.stringify(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,6 +13,11 @@ export async function loadPdf(path: string) {
}
export async function savePdf(doc: PDFDocument, path: string) {
doc.getForm().getFields().forEach((field) => {
if (field instanceof PDFTextField) {
field.disableRichFormatting();
}
});
const pdfBytes = await doc.save();
if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return;
await Deno.writeFile(path, pdfBytes);