mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Restructure/frontend editor (#6404)
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Unit tests for automationConverter import/export round-tripping.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
convertToAutomationConfig,
|
||||
convertToFolderScanningConfig,
|
||||
detectAutomationFormat,
|
||||
parseAutomationConfigJson,
|
||||
parseAutomationFile,
|
||||
parseFolderScanningConfig,
|
||||
} from "@app/utils/automationConverter";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
|
||||
// Minimal stub registry — only the fields automationConverter actually reads.
|
||||
const registry = {
|
||||
merge: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/general/merge-pdfs",
|
||||
},
|
||||
},
|
||||
compress: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
},
|
||||
},
|
||||
ocr: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/misc/ocr-pdf",
|
||||
},
|
||||
},
|
||||
// Dynamic endpoint — endpoint depends on parameters.
|
||||
convert: {
|
||||
operationConfig: {
|
||||
endpoint: (params: Record<string, any>) =>
|
||||
`/api/v1/convert/${params.fromExtension}-to-${params.toExtension}`,
|
||||
},
|
||||
},
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
const sampleAutomation: AutomationConfig = {
|
||||
id: "auto-123",
|
||||
name: "Sample",
|
||||
description: "Test automation",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "merge", parameters: { generateToc: true } },
|
||||
{ operation: "compress", parameters: { compressionLevel: 3 } },
|
||||
],
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("automationConverter", () => {
|
||||
describe("convertToAutomationConfig", () => {
|
||||
test("strips id/createdAt/updatedAt and clones parameters", () => {
|
||||
const result = convertToAutomationConfig(sampleAutomation);
|
||||
expect(result).toEqual({
|
||||
name: "Sample",
|
||||
description: "Test automation",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "merge", parameters: { generateToc: true } },
|
||||
{ operation: "compress", parameters: { compressionLevel: 3 } },
|
||||
],
|
||||
});
|
||||
expect(result).not.toHaveProperty("id");
|
||||
expect(result).not.toHaveProperty("createdAt");
|
||||
expect(result).not.toHaveProperty("updatedAt");
|
||||
// Parameters should be cloned, not the same reference.
|
||||
expect(result.operations[0].parameters).not.toBe(
|
||||
sampleAutomation.operations[0].parameters,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToFolderScanningConfig", () => {
|
||||
test("rewrites operation keys to backend endpoints and adds fileInput", () => {
|
||||
const config = convertToFolderScanningConfig(sampleAutomation, registry);
|
||||
expect(config.pipeline).toEqual([
|
||||
{
|
||||
operation: "/api/v1/general/merge-pdfs",
|
||||
parameters: { generateToc: true, fileInput: "automated" },
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/misc/compress-pdf",
|
||||
parameters: { compressionLevel: 3, fileInput: "automated" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves icon and description so round-tripping keeps UI metadata", () => {
|
||||
const config = convertToFolderScanningConfig(sampleAutomation, registry);
|
||||
expect(config.icon).toBe("CompressIcon");
|
||||
expect(config.description).toBe("Test automation");
|
||||
});
|
||||
|
||||
test("omits icon and description when not set on the source automation", () => {
|
||||
const minimal: AutomationConfig = {
|
||||
id: "min",
|
||||
name: "Minimal",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const config = convertToFolderScanningConfig(minimal, registry);
|
||||
expect(config).not.toHaveProperty("icon");
|
||||
expect(config).not.toHaveProperty("description");
|
||||
});
|
||||
|
||||
test("falls back to operation key when no endpoint is registered", () => {
|
||||
const automation: AutomationConfig = {
|
||||
...sampleAutomation,
|
||||
operations: [{ operation: "unknownTool", parameters: {} }],
|
||||
};
|
||||
const config = convertToFolderScanningConfig(automation, registry);
|
||||
expect(config.pipeline[0].operation).toBe("unknownTool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAutomationFormat", () => {
|
||||
test("detects native Automate JSON", () => {
|
||||
expect(detectAutomationFormat({ operations: [] })).toBe("automate");
|
||||
});
|
||||
test("detects folder-scanning JSON", () => {
|
||||
expect(detectAutomationFormat({ pipeline: [] })).toBe("folderScanning");
|
||||
});
|
||||
test("returns unknown for ambiguous or invalid input", () => {
|
||||
expect(detectAutomationFormat({ pipeline: [], operations: [] })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(detectAutomationFormat(null)).toBe("unknown");
|
||||
expect(detectAutomationFormat("string")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutomationConfigJson", () => {
|
||||
test("accepts a previously exported native Automate JSON", () => {
|
||||
const exported = convertToAutomationConfig(sampleAutomation);
|
||||
const parsed = parseAutomationConfigJson(exported, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.name).toBe("Sample");
|
||||
expect(parsed.automation.operations).toHaveLength(2);
|
||||
expect(parsed.automation.operations[0].operation).toBe("merge");
|
||||
// Icon must round-trip — losing it makes saved entries fall back to the
|
||||
// settings cog, which historically looked like a silent picker bug.
|
||||
expect(parsed.automation.icon).toBe("CompressIcon");
|
||||
});
|
||||
|
||||
test("flags operations not present in the registry", () => {
|
||||
const result = parseAutomationConfigJson(
|
||||
{
|
||||
name: "x",
|
||||
operations: [{ operation: "notARealTool", parameters: {} }],
|
||||
},
|
||||
registry,
|
||||
);
|
||||
expect(result.unresolvedOperations).toEqual(["notARealTool"]);
|
||||
});
|
||||
|
||||
test("throws on missing operations array", () => {
|
||||
expect(() => parseAutomationConfigJson({ name: "x" }, registry)).toThrow(
|
||||
/operations/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFolderScanningConfig", () => {
|
||||
test("round-trips a folder-scan export back to tool IDs", () => {
|
||||
const exported = convertToFolderScanningConfig(
|
||||
sampleAutomation,
|
||||
registry,
|
||||
);
|
||||
const parsed = parseFolderScanningConfig(exported, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.operations.map((o) => o.operation)).toEqual([
|
||||
"merge",
|
||||
"compress",
|
||||
]);
|
||||
// Icon survives the round trip even through the folder-scan format.
|
||||
expect(parsed.automation.icon).toBe("CompressIcon");
|
||||
// The export-time `fileInput: "automated"` marker must not survive import.
|
||||
for (const op of parsed.automation.operations) {
|
||||
expect(op.parameters).not.toHaveProperty("fileInput");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves a dynamic endpoint by replaying it with the imported parameters", () => {
|
||||
const config = {
|
||||
name: "Convert",
|
||||
pipeline: [
|
||||
{
|
||||
operation: "/api/v1/convert/pdf-to-docx",
|
||||
parameters: {
|
||||
fromExtension: "pdf",
|
||||
toExtension: "docx",
|
||||
fileInput: "automated",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = parseFolderScanningConfig(config, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.operations[0].operation).toBe("convert");
|
||||
expect(parsed.automation.operations[0].parameters).toEqual({
|
||||
fromExtension: "pdf",
|
||||
toExtension: "docx",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps unmappable endpoints verbatim and reports them", () => {
|
||||
const config = {
|
||||
name: "Mystery",
|
||||
pipeline: [{ operation: "/api/v1/unknown/op", parameters: {} }],
|
||||
};
|
||||
const parsed = parseFolderScanningConfig(config, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual(["/api/v1/unknown/op"]);
|
||||
expect(parsed.automation.operations[0].operation).toBe(
|
||||
"/api/v1/unknown/op",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws on missing pipeline array", () => {
|
||||
expect(() => parseFolderScanningConfig({ name: "x" }, registry)).toThrow(
|
||||
/pipeline/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutomationFile", () => {
|
||||
test("auto-detects Automate JSON", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
});
|
||||
const result = parseAutomationFile(text, registry);
|
||||
expect(result.format).toBe("automate");
|
||||
});
|
||||
|
||||
test("auto-detects Folder Scanning JSON", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
pipeline: [{ operation: "/api/v1/general/merge-pdfs", parameters: {} }],
|
||||
});
|
||||
const result = parseAutomationFile(text, registry);
|
||||
expect(result.format).toBe("folderScanning");
|
||||
});
|
||||
|
||||
test("rejects mismatched explicit format", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
});
|
||||
expect(() =>
|
||||
parseAutomationFile(text, registry, "folderScanning"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("rejects malformed JSON", () => {
|
||||
expect(() => parseAutomationFile("{ not json", registry)).toThrow(
|
||||
/not valid JSON/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unrecognized shape", () => {
|
||||
expect(() =>
|
||||
parseAutomationFile(JSON.stringify({ foo: "bar" }), registry),
|
||||
).toThrow(/Unrecognized JSON/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Utility functions for converting between automation formats.
|
||||
*
|
||||
* Two on-disk formats are supported:
|
||||
*
|
||||
* 1. **Automate JSON** (native) — mirrors {@link AutomationConfig}; the format
|
||||
* used internally by the Automate tool and persisted in IndexedDB. Operation
|
||||
* names are frontend tool IDs (e.g. "merge", "compress").
|
||||
*
|
||||
* 2. **Folder Scanning JSON** — the format consumed by the backend
|
||||
* PipelineDirectoryProcessor. Operation names are full backend endpoint
|
||||
* paths (e.g. "/api/v1/general/merge-pdfs").
|
||||
*/
|
||||
|
||||
import { AutomationConfig, AutomationOperation } from "@app/types/automation";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
/**
|
||||
* Pipeline configuration format used by folder scanning.
|
||||
*
|
||||
* `description` and `icon` are unused by the backend pipeline runner but are
|
||||
* preserved so a folder-scan export can round-trip cleanly back into the
|
||||
* Automate UI without losing display metadata.
|
||||
*/
|
||||
interface FolderScanningPipeline {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
pipeline: Array<{
|
||||
operation: string;
|
||||
parameters: Record<string, any>;
|
||||
}>;
|
||||
_examples: {
|
||||
outputDir: string;
|
||||
outputFileName: string;
|
||||
};
|
||||
outputDir: string;
|
||||
outputFileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated result returned by {@link parseAutomationFile}.
|
||||
*/
|
||||
export type ParsedAutomationImport =
|
||||
| {
|
||||
format: "automate";
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
}
|
||||
| {
|
||||
format: "folderScanning";
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a filename so it works on Windows / macOS / Linux.
|
||||
*/
|
||||
const sanitizeFilename = (name: string): string =>
|
||||
(name || "automation").replace(/[\\/:*?"<>|]+/g, "_").trim() || "automation";
|
||||
|
||||
/**
|
||||
* Converts an AutomationConfig to a folder scanning pipeline configuration.
|
||||
*/
|
||||
export function convertToFolderScanningConfig(
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): FolderScanningPipeline {
|
||||
return {
|
||||
name: automation.name,
|
||||
...(automation.description ? { description: automation.description } : {}),
|
||||
...(automation.icon ? { icon: automation.icon } : {}),
|
||||
pipeline: automation.operations.map((op) => {
|
||||
const toolId = op.operation as ToolId;
|
||||
const toolEntry = toolRegistry[toolId];
|
||||
const endpointConfig = toolEntry?.operationConfig?.endpoint;
|
||||
|
||||
let endpoint: string | undefined;
|
||||
|
||||
if (typeof endpointConfig === "string") {
|
||||
endpoint = endpointConfig;
|
||||
} else if (typeof endpointConfig === "function") {
|
||||
try {
|
||||
endpoint = endpointConfig(op.parameters);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to resolve dynamic endpoint for operation "${op.operation}". ` +
|
||||
`This may happen if the tool requires specific parameters. ` +
|
||||
`Error: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
console.warn(
|
||||
`No endpoint found for operation "${op.operation}". ` +
|
||||
`This operation may fail in folder scanning. ` +
|
||||
`Using operation type as fallback.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
operation: endpoint || op.operation,
|
||||
parameters: {
|
||||
...op.parameters,
|
||||
fileInput: "automated",
|
||||
},
|
||||
};
|
||||
}),
|
||||
_examples: {
|
||||
outputDir: "{outputFolder}/{folderName}",
|
||||
outputFileName: "{filename}-{pipelineName}-{date}-{time}",
|
||||
},
|
||||
outputDir: "{outputFolder}",
|
||||
outputFileName: "{filename}",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a folder scanning configuration as a JSON file.
|
||||
*/
|
||||
export function downloadFolderScanningConfig(
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): void {
|
||||
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
void downloadFile({
|
||||
data: blob,
|
||||
filename: `${sanitizeFilename(automation.name)}.folder-scan.json`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exportable native Automate JSON object. Strips the IndexedDB
|
||||
* primary key and timestamps so an imported copy looks fresh.
|
||||
*/
|
||||
export function convertToAutomationConfig(
|
||||
automation: AutomationConfig,
|
||||
): Omit<AutomationConfig, "id" | "createdAt" | "updatedAt"> {
|
||||
return {
|
||||
name: automation.name,
|
||||
description: automation.description,
|
||||
icon: automation.icon,
|
||||
operations: automation.operations.map((op) => ({
|
||||
operation: op.operation,
|
||||
parameters: { ...(op.parameters || {}) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an automation in the native Automate JSON format. The file can be
|
||||
* re-imported via {@link parseAutomationFile} to restore the automation on a
|
||||
* different machine or browser profile.
|
||||
*/
|
||||
export function downloadAutomationConfig(automation: AutomationConfig): void {
|
||||
const config = convertToAutomationConfig(automation);
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
void downloadFile({
|
||||
data: blob,
|
||||
filename: `${sanitizeFilename(automation.name)}.automate.json`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an inverse map of `endpoint string` → `frontend tool ID` from the
|
||||
* tool registry. Only static string endpoints are added — dynamic
|
||||
* (function) endpoints are matched at parse time by replaying them.
|
||||
*/
|
||||
function buildEndpointToToolIdMap(
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): Map<string, ToolId> {
|
||||
const map = new Map<string, ToolId>();
|
||||
for (const [toolId, entry] of Object.entries(toolRegistry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string" && !map.has(endpoint)) {
|
||||
map.set(endpoint, toolId as ToolId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try every dynamic endpoint in the registry with the supplied parameters,
|
||||
* returning the first tool ID whose endpoint function produces `targetEndpoint`.
|
||||
*/
|
||||
function findDynamicEndpointMatch(
|
||||
targetEndpoint: string,
|
||||
parameters: Record<string, any>,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): ToolId | undefined {
|
||||
for (const [toolId, entry] of Object.entries(toolRegistry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "function") {
|
||||
try {
|
||||
if (endpoint(parameters) === targetEndpoint) {
|
||||
return toolId as ToolId;
|
||||
}
|
||||
} catch {
|
||||
// Endpoint function expected different parameters — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isToolIdInRegistry = (
|
||||
candidate: string,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): boolean => Object.prototype.hasOwnProperty.call(toolRegistry, candidate);
|
||||
|
||||
/**
|
||||
* Parse a folder-scanning pipeline JSON into the native AutomationConfig
|
||||
* shape. Endpoint paths are reverse-mapped to frontend tool IDs via the
|
||||
* supplied registry; unmappable operations are kept verbatim and reported in
|
||||
* `unresolvedOperations`.
|
||||
*/
|
||||
export function parseFolderScanningConfig(
|
||||
raw: unknown,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): {
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("Invalid folder scanning config: expected JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const pipeline = obj.pipeline;
|
||||
if (!Array.isArray(pipeline)) {
|
||||
throw new Error("Invalid folder scanning config: missing 'pipeline' array");
|
||||
}
|
||||
|
||||
const endpointMap = buildEndpointToToolIdMap(toolRegistry);
|
||||
const unresolved: string[] = [];
|
||||
|
||||
const operations: AutomationOperation[] = pipeline.map(
|
||||
(step: unknown, index: number) => {
|
||||
if (!step || typeof step !== "object") {
|
||||
throw new Error(
|
||||
`Invalid folder scanning config: pipeline[${index}] is not an object`,
|
||||
);
|
||||
}
|
||||
const stepObj = step as Record<string, unknown>;
|
||||
const rawOperation = stepObj.operation;
|
||||
if (typeof rawOperation !== "string" || rawOperation.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid folder scanning config: pipeline[${index}].operation must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
const rawParameters = (stepObj.parameters as Record<string, any>) || {};
|
||||
// Strip the export-time marker so the imported automation runs cleanly.
|
||||
const { fileInput: _fileInput, ...parameters } = rawParameters;
|
||||
|
||||
// 1) Direct frontend-tool-id match (handles the converter's fallback when
|
||||
// no endpoint could be resolved at export time).
|
||||
if (isToolIdInRegistry(rawOperation, toolRegistry)) {
|
||||
return { operation: rawOperation, parameters };
|
||||
}
|
||||
|
||||
// 2) Static endpoint string match.
|
||||
const staticMatch = endpointMap.get(rawOperation);
|
||||
if (staticMatch) {
|
||||
return { operation: staticMatch, parameters };
|
||||
}
|
||||
|
||||
// 3) Dynamic endpoint match — replay each function endpoint with the
|
||||
// parameters we have and look for a match.
|
||||
const dynamicMatch = findDynamicEndpointMatch(
|
||||
rawOperation,
|
||||
parameters,
|
||||
toolRegistry,
|
||||
);
|
||||
if (dynamicMatch) {
|
||||
return { operation: dynamicMatch, parameters };
|
||||
}
|
||||
|
||||
unresolved.push(rawOperation);
|
||||
return { operation: rawOperation, parameters };
|
||||
},
|
||||
);
|
||||
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.length > 0
|
||||
? obj.name
|
||||
: "Imported Automation";
|
||||
|
||||
return {
|
||||
automation: {
|
||||
name,
|
||||
description: typeof obj.description === "string" ? obj.description : "",
|
||||
icon: typeof obj.icon === "string" ? obj.icon : undefined,
|
||||
operations,
|
||||
},
|
||||
unresolvedOperations: unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a native Automate JSON file (a previously-exported AutomationConfig).
|
||||
* The id / createdAt / updatedAt fields are dropped — the storage layer
|
||||
* regenerates them on save.
|
||||
*/
|
||||
export function parseAutomationConfigJson(
|
||||
raw: unknown,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): {
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("Invalid automation config: expected JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const operations = obj.operations;
|
||||
if (!Array.isArray(operations)) {
|
||||
throw new Error("Invalid automation config: missing 'operations' array");
|
||||
}
|
||||
|
||||
const unresolved: string[] = [];
|
||||
const parsedOperations: AutomationOperation[] = operations.map(
|
||||
(op: unknown, index: number) => {
|
||||
if (!op || typeof op !== "object") {
|
||||
throw new Error(
|
||||
`Invalid automation config: operations[${index}] is not an object`,
|
||||
);
|
||||
}
|
||||
const opObj = op as Record<string, unknown>;
|
||||
const operation = opObj.operation;
|
||||
if (typeof operation !== "string" || operation.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid automation config: operations[${index}].operation must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
const parameters = (opObj.parameters as Record<string, any>) || {};
|
||||
if (!isToolIdInRegistry(operation, toolRegistry)) {
|
||||
unresolved.push(operation);
|
||||
}
|
||||
return { operation, parameters };
|
||||
},
|
||||
);
|
||||
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.length > 0
|
||||
? obj.name
|
||||
: "Imported Automation";
|
||||
|
||||
return {
|
||||
automation: {
|
||||
name,
|
||||
description: typeof obj.description === "string" ? obj.description : "",
|
||||
icon: typeof obj.icon === "string" ? obj.icon : undefined,
|
||||
operations: parsedOperations,
|
||||
},
|
||||
unresolvedOperations: unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic format detector. Folder-scanning JSON uses a `pipeline` array;
|
||||
* native Automate JSON uses an `operations` array. Both is invalid.
|
||||
*/
|
||||
export function detectAutomationFormat(
|
||||
raw: unknown,
|
||||
): "automate" | "folderScanning" | "unknown" {
|
||||
if (!raw || typeof raw !== "object") return "unknown";
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const hasPipeline = Array.isArray(obj.pipeline);
|
||||
const hasOperations = Array.isArray(obj.operations);
|
||||
if (hasPipeline && !hasOperations) return "folderScanning";
|
||||
if (hasOperations && !hasPipeline) return "automate";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON file's text content into a normalized AutomationConfig.
|
||||
* Auto-detects the format unless `expectedFormat` is supplied; throws with a
|
||||
* user-readable message on any structural problem.
|
||||
*/
|
||||
export function parseAutomationFile(
|
||||
fileText: string,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
expectedFormat?: "automate" | "folderScanning",
|
||||
): ParsedAutomationImport {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fileText);
|
||||
} catch (err) {
|
||||
throw new Error(`File is not valid JSON: ${(err as Error).message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const detected = detectAutomationFormat(raw);
|
||||
const format = expectedFormat ?? detected;
|
||||
|
||||
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
|
||||
throw new Error(
|
||||
`Expected ${
|
||||
expectedFormat === "automate"
|
||||
? "Automate JSON (operations array)"
|
||||
: "Folder Scanning JSON (pipeline array)"
|
||||
} but file looks like ${
|
||||
detected === "automate" ? "Automate JSON" : "Folder Scanning JSON"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "automate") {
|
||||
const result = parseAutomationConfigJson(raw, toolRegistry);
|
||||
return { format: "automate", ...result };
|
||||
}
|
||||
if (format === "folderScanning") {
|
||||
const result = parseFolderScanningConfig(raw, toolRegistry);
|
||||
return { format: "folderScanning", ...result };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unrecognized JSON shape. Expected an Automate config (operations[]) or a Folder Scanning config (pipeline[]).",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
|
||||
import { AutomationFileProcessor } from "@app/utils/automationFileProcessor";
|
||||
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import { processResponse } from "@app/utils/toolResponseProcessor";
|
||||
|
||||
/**
|
||||
* Process multi-file tool response (handles ZIP or single PDF responses)
|
||||
*/
|
||||
const processMultiFileResponse = async (
|
||||
responseData: Blob,
|
||||
responseHeaders: any,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
preserveBackendFilename?: boolean,
|
||||
): Promise<File[]> => {
|
||||
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
|
||||
if (
|
||||
responseData.type === "application/pdf" ||
|
||||
(responseHeaders && responseHeaders["content-type"] === "application/pdf")
|
||||
) {
|
||||
// Single PDF response - use processResponse to respect preserveBackendFilename
|
||||
const processedFiles = await processResponse(
|
||||
responseData,
|
||||
files,
|
||||
filePrefix,
|
||||
undefined,
|
||||
preserveBackendFilename ? responseHeaders : undefined,
|
||||
);
|
||||
return processedFiles;
|
||||
} else {
|
||||
// ZIP response
|
||||
const result =
|
||||
await AutomationFileProcessor.extractAutomationZipFiles(responseData);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.warn(`⚠️ File processing warnings:`, result.errors);
|
||||
}
|
||||
|
||||
// Apply prefix to files, replacing any existing prefix
|
||||
const processedFiles =
|
||||
filePrefix && !preserveBackendFilename
|
||||
? result.files.map((file) => {
|
||||
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, "");
|
||||
return new File([file], `${filePrefix}${nameWithoutPrefix}`, {
|
||||
type: file.type,
|
||||
});
|
||||
})
|
||||
: result.files;
|
||||
|
||||
return processedFiles;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Core execution function for API requests
|
||||
*/
|
||||
const executeApiRequest = async (
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
preserveBackendFilename?: boolean,
|
||||
): Promise<File[]> => {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: "blob",
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
|
||||
});
|
||||
|
||||
return await processMultiFileResponse(
|
||||
response.data,
|
||||
response.headers,
|
||||
files,
|
||||
filePrefix,
|
||||
preserveBackendFilename,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute single-file tool operation (processes files one at a time)
|
||||
*/
|
||||
const executeSingleFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
): Promise<File[]> => {
|
||||
const resultFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const endpoint =
|
||||
typeof config.endpoint === "function"
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (
|
||||
config.buildFormData as (params: any, file: File) => FormData
|
||||
)(parameters, file);
|
||||
|
||||
const processedFiles = await executeApiRequest(
|
||||
endpoint,
|
||||
formData,
|
||||
[file],
|
||||
filePrefix,
|
||||
config.preserveBackendFilename,
|
||||
);
|
||||
resultFiles.push(...processedFiles);
|
||||
}
|
||||
|
||||
return resultFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute multi-file tool operation (processes all files in one request)
|
||||
*/
|
||||
const executeMultiFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
): Promise<File[]> => {
|
||||
const endpoint =
|
||||
typeof config.endpoint === "function"
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (
|
||||
config.buildFormData as (params: any, files: File[]) => FormData
|
||||
)(parameters, files);
|
||||
|
||||
return await executeApiRequest(
|
||||
endpoint,
|
||||
formData,
|
||||
files,
|
||||
filePrefix,
|
||||
config.preserveBackendFilename,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a tool operation directly without using React hooks
|
||||
*/
|
||||
export const executeToolOperation = async (
|
||||
operationName: string,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
): Promise<File[]> => {
|
||||
return executeToolOperationWithPrefix(
|
||||
operationName,
|
||||
parameters,
|
||||
files,
|
||||
toolRegistry,
|
||||
AUTOMATION_CONSTANTS.FILE_PREFIX,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a tool operation with custom prefix
|
||||
*/
|
||||
export const executeToolOperationWithPrefix = async (
|
||||
operationName: string,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX,
|
||||
): Promise<File[]> => {
|
||||
const config = toolRegistry[operationName as ToolId]?.operationConfig;
|
||||
if (!config) {
|
||||
throw new Error(`Tool operation not supported: ${operationName}`);
|
||||
}
|
||||
|
||||
// Merge with default parameters to ensure all required fields are present
|
||||
const mergedParameters = { ...config.defaultParameters, ...parameters };
|
||||
|
||||
try {
|
||||
// Check if tool uses custom processor (like Convert tool)
|
||||
if (config.customProcessor) {
|
||||
const result = await config.customProcessor(mergedParameters, files);
|
||||
return result.files;
|
||||
}
|
||||
|
||||
// Execute based on tool type
|
||||
if (config.toolType === ToolType.multiFile) {
|
||||
return await executeMultiFileOperation(
|
||||
config,
|
||||
mergedParameters,
|
||||
files,
|
||||
filePrefix,
|
||||
);
|
||||
} else {
|
||||
return await executeSingleFileOperation(
|
||||
config,
|
||||
mergedParameters,
|
||||
files,
|
||||
filePrefix,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ ${operationName} failed:`, error);
|
||||
throw new Error(
|
||||
`${operationName} operation failed: ${error.response?.data || error.message}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an entire automation sequence
|
||||
*/
|
||||
export const executeAutomationSequence = async (
|
||||
automation: any,
|
||||
initialFiles: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
onStepStart?: (stepIndex: number, operationName: string) => void,
|
||||
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
|
||||
onStepError?: (stepIndex: number, error: string) => void,
|
||||
): Promise<File[]> => {
|
||||
console.log(`🚀 Starting automation: ${automation.name || "Unnamed"}`);
|
||||
console.log(`📁 Input: ${initialFiles.length} file(s)`);
|
||||
|
||||
if (!automation?.operations || automation.operations.length === 0) {
|
||||
throw new Error("No operations in automation");
|
||||
}
|
||||
|
||||
let currentFiles = [...initialFiles];
|
||||
const automationPrefix = automation.name
|
||||
? `${automation.name}_`
|
||||
: "automated_";
|
||||
|
||||
for (let i = 0; i < automation.operations.length; i++) {
|
||||
const operation = automation.operations[i];
|
||||
|
||||
console.log(
|
||||
`\n📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`,
|
||||
);
|
||||
console.log(` Input: ${currentFiles.length} file(s)`);
|
||||
|
||||
try {
|
||||
onStepStart?.(i, operation.operation);
|
||||
|
||||
const resultFiles = await executeToolOperationWithPrefix(
|
||||
operation.operation,
|
||||
operation.parameters || {},
|
||||
currentFiles,
|
||||
toolRegistry,
|
||||
i === automation.operations.length - 1 ? automationPrefix : "", // Only add prefix to final step
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Step ${i + 1} completed: ${resultFiles.length} result files`,
|
||||
);
|
||||
currentFiles = resultFiles;
|
||||
onStepComplete?.(i, resultFiles);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Step ${i + 1} failed:`, error);
|
||||
onStepError?.(i, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
|
||||
return currentFiles;
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* File processing utilities specifically for automation workflows
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { ResourceManager } from "@app/utils/resourceManager";
|
||||
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
|
||||
|
||||
export interface AutomationProcessingOptions {
|
||||
timeout?: number;
|
||||
responseType?: "blob" | "json";
|
||||
}
|
||||
|
||||
export interface AutomationProcessingResult {
|
||||
success: boolean;
|
||||
files: File[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class AutomationFileProcessor {
|
||||
/**
|
||||
* Check if a blob is a ZIP file by examining its header
|
||||
*/
|
||||
static isZipFile(blob: Blob): boolean {
|
||||
// This is a simple check - in a real implementation you might want to read the first few bytes
|
||||
// For now, we'll rely on the extraction attempt and fallback
|
||||
return (
|
||||
blob.type === "application/zip" ||
|
||||
blob.type === "application/x-zip-compressed"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files
|
||||
* Extracts all file types (PDFs, images, etc.) except HTML files which stay zipped
|
||||
*/
|
||||
static async extractAutomationZipFiles(
|
||||
blob: Blob,
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const zipFile = ResourceManager.createTimestampedFile(
|
||||
blob,
|
||||
AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX,
|
||||
".zip",
|
||||
"application/zip",
|
||||
);
|
||||
|
||||
// Check if ZIP contains HTML files - if so, keep as ZIP
|
||||
const containsHtml = await zipFileService.containsHtmlFiles(zipFile);
|
||||
if (containsHtml) {
|
||||
// HTML files should stay zipped - return ZIP as-is
|
||||
return {
|
||||
success: true,
|
||||
files: [zipFile],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Extract all files (not just PDFs) - handles images from scanner-image-split, etc.
|
||||
const result = await zipFileService.extractAllFiles(zipFile);
|
||||
|
||||
if (!result.success || result.extractedFiles.length === 0) {
|
||||
// Fallback: keep as ZIP file (might be valid ZIP with extraction issues)
|
||||
return {
|
||||
success: true,
|
||||
files: [zipFile],
|
||||
errors: [
|
||||
`ZIP extraction failed, kept as ZIP: ${result.errors?.join(", ") || "Unknown error"}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: result.extractedFiles,
|
||||
errors: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to extract automation ZIP files, keeping as ZIP:",
|
||||
error,
|
||||
);
|
||||
// Fallback: keep as ZIP file for next automation step to handle
|
||||
const fallbackFile = ResourceManager.createTimestampedFile(
|
||||
blob,
|
||||
AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX,
|
||||
".zip",
|
||||
"application/zip",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: [fallbackFile],
|
||||
errors: [`ZIP extraction failed, kept as ZIP: ${error}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file through an automation step
|
||||
*/
|
||||
static async processAutomationSingleFile(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
originalFileName: string,
|
||||
options: AutomationProcessingOptions = {},
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: options.responseType || "blob",
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [
|
||||
`Automation step failed - HTTP ${response.status}: ${response.statusText}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const resultFile = ResourceManager.createResultFile(
|
||||
response.data,
|
||||
originalFileName,
|
||||
AUTOMATION_CONSTANTS.FILE_PREFIX,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: [resultFile],
|
||||
errors: [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [
|
||||
`Automation step failed: ${error.response?.data || error.message}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple files through an automation step
|
||||
*/
|
||||
static async processAutomationMultipleFiles(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
options: AutomationProcessingOptions = {},
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: options.responseType || "blob",
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [
|
||||
`Automation step failed - HTTP ${response.status}: ${response.statusText}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-file responses are typically ZIP files
|
||||
return await this.extractAutomationZipFiles(response.data);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [
|
||||
`Automation step failed: ${error.response?.data || error.message}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build form data for automation tool operations
|
||||
*/
|
||||
static buildAutomationFormData(
|
||||
parameters: Record<string, any>,
|
||||
files: File | File[],
|
||||
fileFieldName: string = "fileInput",
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add files
|
||||
if (Array.isArray(files)) {
|
||||
files.forEach((file) => formData.append(fileFieldName, file));
|
||||
} else {
|
||||
formData.append(fileFieldName, files);
|
||||
}
|
||||
|
||||
// Add parameters
|
||||
Object.entries(parameters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => formData.append(key, item));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Browser identifier utility for anonymous usage tracking
|
||||
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||
*/
|
||||
|
||||
const BROWSER_ID_KEY = "stirling_browser_id";
|
||||
|
||||
/**
|
||||
* Gets or creates a unique browser identifier
|
||||
* Used for Weekly Active Users (WAU) tracking in no-login mode
|
||||
*/
|
||||
export function getBrowserId(): string {
|
||||
try {
|
||||
// Try to get existing ID from localStorage
|
||||
let browserId = localStorage.getItem(BROWSER_ID_KEY);
|
||||
|
||||
if (!browserId) {
|
||||
// Generate new UUID v4
|
||||
browserId = generateUUID();
|
||||
localStorage.setItem(BROWSER_ID_KEY, browserId);
|
||||
}
|
||||
|
||||
return browserId;
|
||||
} catch (error) {
|
||||
// Fallback to session-based ID if localStorage is unavailable
|
||||
console.warn("localStorage unavailable, using session-based ID", error);
|
||||
return `session_${generateUUID()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// Use crypto.randomUUID if available (modern browsers)
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback to manual UUID generation
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
## Bulk Selection Expressions
|
||||
|
||||
### What this does
|
||||
|
||||
- Lets you select pages using compact expressions instead of typing long CSV lists.
|
||||
- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count.
|
||||
- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending.
|
||||
|
||||
### Basic forms
|
||||
|
||||
- Numbers: `5` selects page 5.
|
||||
- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3` → `3-7`).
|
||||
- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10.
|
||||
|
||||
You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work.
|
||||
|
||||
### Logical operators
|
||||
|
||||
- OR (union): `,` or `|` or the word `or`
|
||||
- AND (intersection): `&` or the word `and`
|
||||
- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)`
|
||||
|
||||
Operator precedence (from highest to lowest):
|
||||
1) `!` (NOT)
|
||||
2) `&` / `and` (AND)
|
||||
3) `,` / `|` / `or` (OR)
|
||||
|
||||
Use parentheses `(...)` to override precedence where needed.
|
||||
|
||||
### Keywords and progressions
|
||||
|
||||
- Keywords (case-insensitive):
|
||||
- `even`: all even pages (2, 4, 6, ...)
|
||||
- `odd`: all odd pages (1, 3, 5, ...)
|
||||
|
||||
- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1`
|
||||
- `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded.
|
||||
- `k` must be a positive integer (≥ 1). `c` can be any integer (including negative).
|
||||
- Examples:
|
||||
- `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive
|
||||
- `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd)
|
||||
- `3n+1` → 1,4,7,10,13,...
|
||||
|
||||
All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`.
|
||||
|
||||
### Parentheses
|
||||
|
||||
- Group with parentheses to control evaluation order and combine NOT with groups.
|
||||
- Examples:
|
||||
- `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10)
|
||||
- `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10
|
||||
- `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20
|
||||
- `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20
|
||||
|
||||
### Whitespace and case
|
||||
|
||||
- Whitespace is ignored: ` odd & 1 - 7` is valid.
|
||||
- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work.
|
||||
|
||||
### Universe, clamping, deduplication
|
||||
|
||||
- The selection universe is the document's pages `[1..maxPages]`.
|
||||
- Numbers outside the universe are discarded.
|
||||
- Ranges are clamped to `[1..maxPages]` (e.g., `0-5` → `1-5`, `9-999` in a 10-page doc → `9-10`).
|
||||
- Duplicates are removed; the final result is sorted ascending.
|
||||
|
||||
### Examples
|
||||
|
||||
- `1-10 & 2n & !5-7` → 2,4,8,10
|
||||
- `odd` → 1,3,5,7,9,...
|
||||
- `even` → 2,4,6,8,10,...
|
||||
- `2n-1` → 1,3,5,7,9,...
|
||||
- `3n+1` → 4,7,10,13,16,... (up to max pages)
|
||||
- `1-3, 8-9` → 1,2,3,8,9
|
||||
- `1-2 | 9-10 or 5` → 1,2,5,9,10
|
||||
- `!(1-5)` → remove the first five pages from the universe
|
||||
- `!(10-20 & !2n)` → complement of odd pages between 10 and 20
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
|
||||
|
||||
describe("parseSelection", () => {
|
||||
const max = 120;
|
||||
|
||||
it("1) parses single numbers", () => {
|
||||
expect(parseSelection("5", max)).toEqual([5]);
|
||||
});
|
||||
|
||||
it("2) parses simple range", () => {
|
||||
expect(parseSelection("3-7", max)).toEqual([3, 4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
it("3) parses multiple numbers and ranges via comma OR", () => {
|
||||
expect(parseSelection("1,3-5,10", max)).toEqual([1, 3, 4, 5, 10]);
|
||||
});
|
||||
|
||||
it("4) respects bounds (clamps to 1..max and filters invalid)", () => {
|
||||
expect(parseSelection("0, -2, 1-2, 9999", max)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("5) supports even keyword", () => {
|
||||
expect(parseSelection("even", 10)).toEqual([2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it("6) supports odd keyword", () => {
|
||||
expect(parseSelection("odd", 10)).toEqual([1, 3, 5, 7, 9]);
|
||||
});
|
||||
|
||||
it("7) supports 2n progression", () => {
|
||||
expect(parseSelection("2n", 12)).toEqual([2, 4, 6, 8, 10, 12]);
|
||||
});
|
||||
|
||||
it("8) supports kn±c progression (3n+1)", () => {
|
||||
expect(parseSelection("3n+1", 10)).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
it("9) supports kn±c progression (4n-1)", () => {
|
||||
expect(parseSelection("4n-1", 15)).toEqual([3, 7, 11, 15]);
|
||||
});
|
||||
|
||||
it("10) supports logical AND (&) intersection", () => {
|
||||
// even AND 1-10 => even numbers within 1..10
|
||||
expect(parseSelection("even & 1-10", 20)).toEqual([2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it("11) supports logical OR with comma", () => {
|
||||
expect(parseSelection("1-3, 8-9", 20)).toEqual([1, 2, 3, 8, 9]);
|
||||
});
|
||||
|
||||
it("12) supports logical OR with | and word or", () => {
|
||||
expect(parseSelection("1-2 | 9-10 or 5", 20)).toEqual([1, 2, 5, 9, 10]);
|
||||
});
|
||||
|
||||
it("13) supports NOT operator !", () => {
|
||||
// !1-5 within max=10 -> 6..10
|
||||
expect(parseSelection("!1-5", 10)).toEqual([6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("14) supports combination: 1-10 & 2n & !5-7", () => {
|
||||
expect(parseSelection("1-10 & 2n & !5-7", 20)).toEqual([2, 4, 8, 10]);
|
||||
});
|
||||
|
||||
it("15) preserves precedence: AND over OR", () => {
|
||||
// 1-10 & even, 15 OR => ( (1-10 & even) , 15 )
|
||||
expect(parseSelection("1-10 & even, 15", 20)).toEqual([2, 4, 6, 8, 10, 15]);
|
||||
});
|
||||
|
||||
it("16) handles whitespace and case-insensitive keywords", () => {
|
||||
expect(parseSelection(" OdD & 1-7 ", 10)).toEqual([1, 3, 5, 7]);
|
||||
});
|
||||
|
||||
it("17) progression plus range: 2n | 9-11 within 12", () => {
|
||||
expect(parseSelection("2n | 9-11", 12)).toEqual([
|
||||
2, 4, 6, 8, 9, 10, 11, 12,
|
||||
]);
|
||||
});
|
||||
|
||||
it("18) complex: (2n-1 & 1-20) & ! (5-7)", () => {
|
||||
expect(parseSelection("2n-1 & 1-20 & !5-7", 20)).toEqual([
|
||||
1, 3, 9, 11, 13, 15, 17, 19,
|
||||
]);
|
||||
});
|
||||
|
||||
it("19) falls back to CSV when expression malformed", () => {
|
||||
// malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty
|
||||
expect(parseSelection("2x", 10)).toEqual([]);
|
||||
// malformed middle; still fallback handles CSV bits
|
||||
expect(parseSelection("1, 3-5, foo, 9", 10)).toEqual([1, 3, 4, 5, 9]);
|
||||
});
|
||||
|
||||
it("20) clamps ranges that exceed bounds", () => {
|
||||
expect(parseSelection("0-5, 9-10", 10)).toEqual([1, 2, 3, 4, 5, 9, 10]);
|
||||
});
|
||||
|
||||
it("21) supports parentheses to override precedence", () => {
|
||||
// Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15]
|
||||
// With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10]
|
||||
expect(parseSelection("1-10 & (even, 15)", 20)).toEqual([2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it("22) NOT over a grouped intersection", () => {
|
||||
// !(10-20 & !2n) within 1..25
|
||||
// Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded
|
||||
// Complement in 1..25 removes those, keeping others
|
||||
const result = parseSelection("!(10-20 & !2n)", 25);
|
||||
expect(result).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20, 21, 22, 23, 24, 25,
|
||||
]);
|
||||
});
|
||||
|
||||
it("23) nested parentheses with progressions", () => {
|
||||
expect(parseSelection("(2n | 3n+1) & 1-20", 50)).toEqual([
|
||||
1, 2, 4, 6, 7, 8, 10, 12, 13, 14, 16, 18, 19, 20,
|
||||
]);
|
||||
});
|
||||
|
||||
it("24) parentheses with NOT directly on group", () => {
|
||||
expect(parseSelection("!(1-5, odd)", 10)).toEqual([6, 8, 10]);
|
||||
});
|
||||
|
||||
it("25) whitespace within parentheses is ignored", () => {
|
||||
expect(parseSelection("( 1 - 3 , 6 )", 10)).toEqual([1, 2, 3, 6]);
|
||||
});
|
||||
|
||||
it("26) malformed missing closing parenthesis falls back to CSV", () => {
|
||||
// Expression parse should fail; fallback CSV should pick numbers only
|
||||
expect(parseSelection("(1-3, 6", 10)).toEqual([6]);
|
||||
});
|
||||
|
||||
it("27) nested NOT and AND with parentheses", () => {
|
||||
// !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9
|
||||
expect(parseSelection("!(odd & 5-9)", 12)).toEqual([
|
||||
1, 2, 3, 4, 6, 8, 10, 11, 12,
|
||||
]);
|
||||
});
|
||||
|
||||
it("28) deep nesting and mixing operators", () => {
|
||||
const expr = "(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)";
|
||||
expect(parseSelection(expr, 20)).toEqual([
|
||||
1, 2, 4, 5, 7, 9, 10, 13, 16, 19,
|
||||
]);
|
||||
});
|
||||
|
||||
it("31) word NOT works like ! for terms", () => {
|
||||
expect(parseSelection("not 1-3", 6)).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it("32) word NOT works like ! for groups", () => {
|
||||
expect(parseSelection("not (odd & 1-6)", 8)).toEqual([2, 4, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it("29) parentheses around a single term has no effect", () => {
|
||||
expect(parseSelection("(even)", 8)).toEqual([2, 4, 6, 8]);
|
||||
});
|
||||
|
||||
it("30) redundant nested parentheses", () => {
|
||||
expect(parseSelection("(((1-3))), ((2n))", 6)).toEqual([1, 2, 3, 4, 6]);
|
||||
});
|
||||
|
||||
// Additional edge cases and comprehensive coverage
|
||||
it("33) handles empty input gracefully", () => {
|
||||
expect(parseSelection("", 10)).toEqual([]);
|
||||
expect(parseSelection(" ", 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it("34) handles zero or negative maxPages", () => {
|
||||
expect(parseSelection("1-10", 0)).toEqual([]);
|
||||
expect(parseSelection("1-10", -5)).toEqual([]);
|
||||
});
|
||||
|
||||
it("35) handles large progressions efficiently", () => {
|
||||
expect(parseSelection("100n", 1000)).toEqual([
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("36) handles progressions with large offsets", () => {
|
||||
expect(parseSelection("5n+97", 100)).toEqual([97]);
|
||||
expect(parseSelection("3n-2", 10)).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
it("37) mixed case keywords work correctly", () => {
|
||||
expect(parseSelection("EVEN & Odd", 6)).toEqual([]);
|
||||
expect(parseSelection("Even OR odd", 6)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("38) complex nested expressions with all operators", () => {
|
||||
const expr = "(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)";
|
||||
// (1-20 & even) = [2,4,6,8,10,12,14,16,18,20]
|
||||
// (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19]
|
||||
// (3n+1 & 1-10) = [1,4,7,10]
|
||||
// Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20]
|
||||
expect(parseSelection(expr, 20)).toEqual([
|
||||
1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20,
|
||||
]);
|
||||
});
|
||||
|
||||
it("39) multiple NOT operators in sequence", () => {
|
||||
expect(parseSelection("not not 1-5", 10)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(parseSelection("!!!1-3", 10)).toEqual([4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("40) edge case: single page selection", () => {
|
||||
expect(parseSelection("1", 1)).toEqual([1]);
|
||||
expect(parseSelection("5", 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it("41) backwards ranges are handled correctly", () => {
|
||||
expect(parseSelection("10-5", 15)).toEqual([5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("42) progressions that start beyond maxPages", () => {
|
||||
expect(parseSelection("10n+50", 40)).toEqual([]);
|
||||
expect(parseSelection("5n+35", 40)).toEqual([35, 40]);
|
||||
});
|
||||
|
||||
it("43) complex operator precedence with mixed syntax", () => {
|
||||
// AND has higher precedence than OR
|
||||
expect(parseSelection("1-3, 5-7 & even", 10)).toEqual([1, 2, 3, 6]);
|
||||
expect(parseSelection("1-3 | 5-7 and even", 10)).toEqual([1, 2, 3, 6]);
|
||||
});
|
||||
|
||||
it("44) whitespace tolerance in complex expressions", () => {
|
||||
const expr1 = "1-5&even|odd&!3";
|
||||
const expr2 = " 1 - 5 & even | odd & ! 3 ";
|
||||
expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10));
|
||||
});
|
||||
|
||||
it("45) fallback behavior with partial valid expressions", () => {
|
||||
// Should fallback and extract valid CSV parts
|
||||
expect(parseSelection("1, 2-4, invalid, 7", 10)).toEqual([1, 2, 3, 4, 7]);
|
||||
expect(parseSelection("1-3, @#$, 8-9", 10)).toEqual([1, 2, 3, 8, 9]);
|
||||
});
|
||||
|
||||
it("46) progressions with k=1 (equivalent to n)", () => {
|
||||
expect(parseSelection("1n", 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(parseSelection("1n+2", 5)).toEqual([2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("47) very large ranges are clamped correctly", () => {
|
||||
expect(parseSelection("1-999999", 10)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
|
||||
]);
|
||||
// Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work
|
||||
expect(parseSelection("0-5", 10)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("48) multiple comma-separated ranges", () => {
|
||||
expect(parseSelection("1-2, 4-5, 7-8, 10", 10)).toEqual([
|
||||
1, 2, 4, 5, 7, 8, 10,
|
||||
]);
|
||||
});
|
||||
|
||||
it("49) combination of all features in one expression", () => {
|
||||
const expr = "(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n";
|
||||
const result = parseSelection(expr, 100);
|
||||
// This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toContain(50);
|
||||
expect(result).toContain(100);
|
||||
});
|
||||
|
||||
it("50) stress test with deeply nested parentheses", () => {
|
||||
const expr = "((((1-5)))) & ((((even)))) | ((((odd & 7-9))))";
|
||||
expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1")
|
||||
// into a list of page numbers within [1, maxPages].
|
||||
|
||||
/*
|
||||
Supported grammar (case-insensitive for words):
|
||||
expression := disjunction
|
||||
disjunction := conjunction ( ("," | "|" | "or") conjunction )*
|
||||
conjunction := unary ( ("&" | "and") unary )*
|
||||
unary := ("!" unary) | ("not" unary) | primary
|
||||
primary := "(" expression ")" | range | progression | keyword | number
|
||||
range := number "-" number // inclusive
|
||||
progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0
|
||||
keyword := "even" | "odd"
|
||||
number := digits (>= 1)
|
||||
|
||||
Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR)
|
||||
Associativity: left-to-right within the same precedence level
|
||||
|
||||
Notes:
|
||||
- Whitespace is ignored.
|
||||
- The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe.
|
||||
- Out-of-bounds numbers are clamped in ranges and ignored as singletons.
|
||||
- On parse failure, the parser falls back to CSV (numbers and ranges separated by commas).
|
||||
|
||||
Examples:
|
||||
1-10 & even -> even pages between 1 and 10
|
||||
!(5-7) -> all pages except 5..7
|
||||
3n+1 -> 1,4,7,... (n starts at 0)
|
||||
(2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20
|
||||
*/
|
||||
|
||||
export function parseSelection(input: string, maxPages: number): number[] {
|
||||
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
|
||||
if (clampedMax === 0) return [];
|
||||
|
||||
const trimmed = (input || "").trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
|
||||
try {
|
||||
const parser = new ExpressionParser(trimmed, clampedMax);
|
||||
const resultSet = parser.parse();
|
||||
return toSortedArray(resultSet);
|
||||
} catch {
|
||||
// Fallback: simple CSV parser (e.g., "1,3,5-10")
|
||||
return toSortedArray(parseCsvFallback(trimmed, clampedMax));
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSelectionWithDiagnostics(
|
||||
input: string,
|
||||
maxPages: number,
|
||||
options?: { strict?: boolean },
|
||||
): { pages: number[]; warning?: string } {
|
||||
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
|
||||
if (clampedMax === 0) return { pages: [] };
|
||||
|
||||
const trimmed = (input || "").trim();
|
||||
if (trimmed.length === 0) return { pages: [] };
|
||||
|
||||
try {
|
||||
const parser = new ExpressionParser(trimmed, clampedMax);
|
||||
const resultSet = parser.parse();
|
||||
return { pages: toSortedArray(resultSet) };
|
||||
} catch (err) {
|
||||
if (options?.strict) {
|
||||
throw err;
|
||||
}
|
||||
const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax));
|
||||
const tokens = trimmed
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const bad = tokens.find((tok) => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok));
|
||||
const warning = `Malformed expression${bad ? ` at: '${bad}'` : ""}. Falling back to CSV interpretation.`;
|
||||
return { pages, warning };
|
||||
}
|
||||
}
|
||||
|
||||
function toSortedArray(set: Set<number>): number[] {
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function parseCsvFallback(input: string, max: number): Set<number> {
|
||||
const result = new Set<number>();
|
||||
const parts = input
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const start = clampToRange(parseInt(rangeMatch[1], 10), 1, max);
|
||||
const end = clampToRange(parseInt(rangeMatch[2], 10), 1, max);
|
||||
if (Number.isFinite(start) && Number.isFinite(end)) {
|
||||
const [lo, hi] = start <= end ? [start, end] : [end, start];
|
||||
for (let i = lo; i <= hi; i++) result.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Accept only pure positive integers (no signs, no letters)
|
||||
if (/^\d+$/.test(part)) {
|
||||
const n = parseInt(part, 10);
|
||||
if (Number.isFinite(n) && n >= 1 && n <= max) result.add(n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampToRange(v: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(v)) return NaN as unknown as number;
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
class ExpressionParser {
|
||||
private readonly src: string;
|
||||
private readonly max: number;
|
||||
private idx: number = 0;
|
||||
|
||||
constructor(source: string, maxPages: number) {
|
||||
this.src = source;
|
||||
this.max = maxPages;
|
||||
}
|
||||
|
||||
parse(): Set<number> {
|
||||
this.skipWs();
|
||||
const set = this.parseDisjunction();
|
||||
this.skipWs();
|
||||
// If there are leftover non-space characters, treat as error
|
||||
if (this.idx < this.src.length) {
|
||||
throw new Error("Unexpected trailing input");
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private parseDisjunction(): Set<number> {
|
||||
let left = this.parseConjunction();
|
||||
while (true) {
|
||||
this.skipWs();
|
||||
const op = this.peekWordOrSymbol();
|
||||
if (!op) break;
|
||||
if (op.type === "symbol" && (op.value === "," || op.value === "|")) {
|
||||
this.consume(op.length);
|
||||
const right = this.parseConjunction();
|
||||
left = union(left, right);
|
||||
continue;
|
||||
}
|
||||
if (op.type === "word" && op.value === "or") {
|
||||
this.consume(op.length);
|
||||
const right = this.parseConjunction();
|
||||
left = union(left, right);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseConjunction(): Set<number> {
|
||||
let left = this.parseUnary();
|
||||
while (true) {
|
||||
this.skipWs();
|
||||
const op = this.peekWordOrSymbol();
|
||||
if (!op) break;
|
||||
if (op.type === "symbol" && op.value === "&") {
|
||||
this.consume(op.length);
|
||||
const right = this.parseUnary();
|
||||
left = intersect(left, right);
|
||||
continue;
|
||||
}
|
||||
if (op.type === "word" && op.value === "and") {
|
||||
this.consume(op.length);
|
||||
const right = this.parseUnary();
|
||||
left = intersect(left, right);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseUnary(): Set<number> {
|
||||
this.skipWs();
|
||||
if (this.peek("!")) {
|
||||
this.consume(1);
|
||||
const inner = this.parseUnary();
|
||||
return complement(inner, this.max);
|
||||
}
|
||||
// Word-form NOT
|
||||
if (this.tryConsumeNot()) {
|
||||
const inner = this.parseUnary();
|
||||
return complement(inner, this.max);
|
||||
}
|
||||
return this.parsePrimary();
|
||||
}
|
||||
|
||||
private parsePrimary(): Set<number> {
|
||||
this.skipWs();
|
||||
|
||||
// Parenthesized expression: '(' expression ')'
|
||||
if (this.peek("(")) {
|
||||
this.consume(1);
|
||||
const inner = this.parseDisjunction();
|
||||
this.skipWs();
|
||||
if (!this.peek(")")) throw new Error("Expected )");
|
||||
this.consume(1);
|
||||
return inner;
|
||||
}
|
||||
|
||||
// Keywords: even / odd
|
||||
const keyword = this.tryReadKeyword();
|
||||
if (keyword) {
|
||||
if (keyword === "even") return this.buildEven();
|
||||
if (keyword === "odd") return this.buildOdd();
|
||||
}
|
||||
|
||||
// Progression: k n ( +/- c )?
|
||||
const progression = this.tryReadProgression();
|
||||
if (progression) {
|
||||
return this.buildProgression(progression.k, progression.c);
|
||||
}
|
||||
|
||||
// Number or Range
|
||||
const num = this.tryReadNumber();
|
||||
if (num !== null) {
|
||||
this.skipWs();
|
||||
if (this.peek("-")) {
|
||||
// Range
|
||||
this.consume(1);
|
||||
this.skipWs();
|
||||
const end = this.readRequiredNumber();
|
||||
return this.buildRange(num, end);
|
||||
}
|
||||
return this.buildSingleton(num);
|
||||
}
|
||||
|
||||
// If nothing matched, error
|
||||
throw new Error("Expected primary");
|
||||
}
|
||||
|
||||
private buildSingleton(n: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
if (n >= 1 && n <= this.max) set.add(n);
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildRange(a: number, b: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
let start = a,
|
||||
end = b;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return set;
|
||||
if (start > end) [start, end] = [end, start];
|
||||
start = Math.max(1, start);
|
||||
end = Math.min(this.max, end);
|
||||
for (let i = start; i <= end; i++) set.add(i);
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildProgression(k: number, c: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
if (!(k >= 1)) return set;
|
||||
// n starts at 0: k*n + c, for n=0,1,2,... while within [1..max]
|
||||
for (let n = 0; ; n++) {
|
||||
const value = k * n + c;
|
||||
if (value > this.max) break;
|
||||
if (value >= 1) set.add(value);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildEven(): Set<number> {
|
||||
return this.buildProgression(2, 0);
|
||||
}
|
||||
|
||||
private buildOdd(): Set<number> {
|
||||
return this.buildProgression(2, -1);
|
||||
}
|
||||
|
||||
private tryReadKeyword(): "even" | "odd" | null {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) return null;
|
||||
const lower = word.toLowerCase();
|
||||
if (lower === "even" || lower === "odd") {
|
||||
return lower as "even" | "odd";
|
||||
}
|
||||
// Not a keyword; rewind
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryReadProgression(): { k: number; c: number } | null {
|
||||
const start = this.idx;
|
||||
this.skipWs();
|
||||
const k = this.tryReadNumber();
|
||||
if (k === null) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
this.skipWs();
|
||||
// Optional '*'
|
||||
if (this.peek("*")) this.consume(1);
|
||||
this.skipWs();
|
||||
if (!this.peek("n") && !this.peek("N")) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
this.consume(1); // consume 'n'
|
||||
this.skipWs();
|
||||
// Optional (+|-) c
|
||||
let c = 0;
|
||||
if (this.peek("+") || this.peek("-")) {
|
||||
const sign = this.src[this.idx];
|
||||
this.consume(1);
|
||||
this.skipWs();
|
||||
const cVal = this.tryReadNumber();
|
||||
if (cVal === null) {
|
||||
this.idx = start;
|
||||
return null;
|
||||
}
|
||||
c = sign === "-" ? -cVal : cVal;
|
||||
}
|
||||
return { k, c };
|
||||
}
|
||||
|
||||
private tryReadNumber(): number | null {
|
||||
this.skipWs();
|
||||
const m = this.src.slice(this.idx).match(/^(\d+)/);
|
||||
if (!m) return null;
|
||||
this.consume(m[1].length);
|
||||
const num = parseInt(m[1], 10);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
private readRequiredNumber(): number {
|
||||
const n = this.tryReadNumber();
|
||||
if (n === null) throw new Error("Expected number");
|
||||
return n;
|
||||
}
|
||||
|
||||
private readWord(): string | null {
|
||||
this.skipWs();
|
||||
const m = this.src.slice(this.idx).match(/^([A-Za-z]+)/);
|
||||
if (!m) return null;
|
||||
this.consume(m[1].length);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
private tryConsumeNot(): boolean {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) {
|
||||
this.idx = start;
|
||||
return false;
|
||||
}
|
||||
if (word.toLowerCase() === "not") {
|
||||
return true;
|
||||
}
|
||||
this.idx = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
private peekWordOrSymbol(): {
|
||||
type: "word" | "symbol";
|
||||
value: string;
|
||||
raw: string;
|
||||
length: number;
|
||||
} | null {
|
||||
this.skipWs();
|
||||
if (this.idx >= this.src.length) return null;
|
||||
const ch = this.src[this.idx];
|
||||
if (/[A-Za-z]/.test(ch)) {
|
||||
const start = this.idx;
|
||||
const word = this.readWord();
|
||||
if (!word) return null;
|
||||
const lower = word.toLowerCase();
|
||||
// Always rewind; the caller will consume if it uses this token
|
||||
const len = word.length;
|
||||
this.idx = start;
|
||||
if (lower === "and" || lower === "or") {
|
||||
return { type: "word", value: lower, raw: word, length: len };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (ch === "&" || ch === "|" || ch === ",") {
|
||||
return { type: "symbol", value: ch, raw: ch, length: 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private skipWs() {
|
||||
while (this.idx < this.src.length && /\s/.test(this.src[this.idx]))
|
||||
this.idx++;
|
||||
}
|
||||
|
||||
private peek(s: string): boolean {
|
||||
return this.src.startsWith(s, this.idx);
|
||||
}
|
||||
|
||||
private consume(n: number) {
|
||||
this.idx += n;
|
||||
}
|
||||
}
|
||||
|
||||
function union(a: Set<number>, b: Set<number>): Set<number> {
|
||||
if (a.size === 0) return new Set(b);
|
||||
if (b.size === 0) return new Set(a);
|
||||
const out = new Set<number>(a);
|
||||
for (const v of b) out.add(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function intersect(a: Set<number>, b: Set<number>): Set<number> {
|
||||
if (a.size === 0 || b.size === 0) return new Set<number>();
|
||||
const out = new Set<number>();
|
||||
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
||||
for (const v of small) if (large.has(v)) out.add(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function complement(a: Set<number>, max: number): Set<number> {
|
||||
const out = new Set<number>();
|
||||
for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Pure helper utilities for building and manipulating bulk page selection expressions
|
||||
|
||||
export type LogicalOperator = "and" | "or" | "not" | "even" | "odd";
|
||||
|
||||
// Returns a new CSV expression with expr appended.
|
||||
// If current ends with an operator token, expr is appended directly.
|
||||
// Otherwise, it is joined with " or ".
|
||||
export function appendExpression(currentInput: string, expr: string): string {
|
||||
const current = (currentInput || "").trim();
|
||||
if (!current) return expr;
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
|
||||
// Add space if operator doesn't already have one
|
||||
if (endsWithOperator) {
|
||||
const needsSpace = !current.endsWith(" ");
|
||||
return `${current}${needsSpace ? " " : ""}${expr}`;
|
||||
}
|
||||
return `${current} or ${expr}`;
|
||||
}
|
||||
|
||||
// Smartly inserts/normalizes a logical operator at the end of the current input.
|
||||
// Produces a trailing space to allow the next token to be typed naturally.
|
||||
export function insertOperatorSmart(
|
||||
currentInput: string,
|
||||
op: LogicalOperator,
|
||||
): string {
|
||||
const text = (currentInput || "").trim();
|
||||
// Handle 'even' and 'odd' as page selection expressions, not logical operators
|
||||
if (op === "even" || op === "odd") {
|
||||
if (text.length === 0) return `${op} `;
|
||||
// If current input ends with a logical operator, append the page selection with proper spacing
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
|
||||
if (endsWithOperator) {
|
||||
// Add space if the operator doesn't already have one
|
||||
const needsSpace = !text.endsWith(" ");
|
||||
return `${text}${needsSpace ? " " : ""}${op} `;
|
||||
}
|
||||
return `${text} or ${op} `;
|
||||
}
|
||||
|
||||
if (text.length === 0) return `${op} `;
|
||||
|
||||
// Extract up to the last two operator tokens (words or symbols) from the end
|
||||
const tokens: string[] = [];
|
||||
let rest = text;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
|
||||
if (!m || m.index === undefined) break;
|
||||
const raw = m[1].toLowerCase();
|
||||
const word =
|
||||
raw === "&"
|
||||
? "and"
|
||||
: raw === "|" || raw === ","
|
||||
? "or"
|
||||
: raw === "!"
|
||||
? "not"
|
||||
: raw;
|
||||
tokens.unshift(word);
|
||||
rest = rest.slice(0, m.index).trimEnd();
|
||||
}
|
||||
|
||||
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
|
||||
const click = op; // desired operator
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return emit(text, click);
|
||||
}
|
||||
|
||||
// Normalize to allowed set
|
||||
const phrase = tokens.join(" ");
|
||||
const allowed = new Set(["and", "or", "not", "and not", "or not"]);
|
||||
|
||||
// Helpers for transitions from a single trailing token
|
||||
const fromSingle = (t: string): string => {
|
||||
if (t === "and") {
|
||||
if (click === "and") return "and";
|
||||
if (click === "or") return "or"; // 'and or' is invalid, so just use 'or'
|
||||
return "and not";
|
||||
}
|
||||
if (t === "or") {
|
||||
if (click === "and") return "and";
|
||||
if (click === "or") return "or";
|
||||
return "or not";
|
||||
}
|
||||
// t === 'not'
|
||||
if (click === "and") return "and";
|
||||
if (click === "or") return "or";
|
||||
return "not";
|
||||
};
|
||||
|
||||
// From combined phrase
|
||||
const fromCombo = (p: string): string => {
|
||||
if (p === "and not") {
|
||||
if (click === "not") return "and not";
|
||||
if (click === "and") return "and";
|
||||
if (click === "or") return "or"; // 'and not or' is invalid, so just use 'or'
|
||||
return "and not";
|
||||
}
|
||||
if (p === "or not") {
|
||||
if (click === "not") return "or not";
|
||||
if (click === "or") return "or";
|
||||
if (click === "and") return "and"; // 'or not and' is invalid, so just use 'and'
|
||||
return "or not";
|
||||
}
|
||||
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
|
||||
return click;
|
||||
};
|
||||
|
||||
const base = rest.trim();
|
||||
const nextPhrase =
|
||||
tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
|
||||
if (!allowed.has(nextPhrase)) {
|
||||
return emit(base, click);
|
||||
}
|
||||
return emit(base, nextPhrase);
|
||||
}
|
||||
|
||||
// Expression builders for Advanced actions
|
||||
export function firstNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
|
||||
return `1-${end}`;
|
||||
}
|
||||
|
||||
export function lastNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const count = Math.max(1, Math.floor(n));
|
||||
const start = Math.max(1, maxPages - count + 1);
|
||||
if (maxPages <= 0) return null;
|
||||
return `${start}-${maxPages}`;
|
||||
}
|
||||
|
||||
export function everyNthExpression(n: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return `${Math.max(1, Math.floor(n))}n`;
|
||||
}
|
||||
|
||||
export function rangeExpression(
|
||||
start: number,
|
||||
end: number,
|
||||
maxPages: number,
|
||||
): string | null {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
let s = Math.floor(start);
|
||||
let e = Math.floor(end);
|
||||
if (s > e) [s, e] = [e, s];
|
||||
s = Math.max(1, s);
|
||||
e = maxPages > 0 ? Math.min(maxPages, e) : e;
|
||||
return `${s}-${e}`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Utility functions for handling click events in navigation components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determines if a click event is a "special" click that should use browser's default navigation
|
||||
* instead of SPA navigation. Special clicks include:
|
||||
* - Ctrl+click (or Cmd+click on Mac)
|
||||
* - Shift+click
|
||||
* - Middle mouse button click
|
||||
*/
|
||||
export function isSpecialClick(e: React.MouseEvent): boolean {
|
||||
return e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a click event for SPA navigation, but allows special clicks to use browser defaults
|
||||
*
|
||||
* @param e - The click event
|
||||
* @param handleClick - Function to execute for regular clicks (SPA navigation)
|
||||
* @returns true if the event was handled as a special click, false if it was handled as regular click
|
||||
*/
|
||||
export function handleUnlessSpecialClick(
|
||||
e: React.MouseEvent,
|
||||
handleClick: () => void,
|
||||
): boolean {
|
||||
if (isSpecialClick(e)) {
|
||||
return true; // Let browser handle via href
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Unit tests for convertUtils
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
getEndpointName,
|
||||
getEndpointUrl,
|
||||
isConversionSupported,
|
||||
isImageFormat,
|
||||
} from "@app/utils/convertUtils";
|
||||
|
||||
describe("convertUtils", () => {
|
||||
describe("getEndpointName", () => {
|
||||
test("should return correct endpoint names for all supported conversions", () => {
|
||||
// PDF to Image formats
|
||||
expect(getEndpointName("pdf", "png")).toBe("pdf-to-img");
|
||||
expect(getEndpointName("pdf", "jpg")).toBe("pdf-to-img");
|
||||
expect(getEndpointName("pdf", "gif")).toBe("pdf-to-img");
|
||||
expect(getEndpointName("pdf", "tiff")).toBe("pdf-to-img");
|
||||
expect(getEndpointName("pdf", "bmp")).toBe("pdf-to-img");
|
||||
expect(getEndpointName("pdf", "webp")).toBe("pdf-to-img");
|
||||
|
||||
// PDF to Office formats
|
||||
expect(getEndpointName("pdf", "docx")).toBe("pdf-to-word");
|
||||
expect(getEndpointName("pdf", "odt")).toBe("pdf-to-word");
|
||||
expect(getEndpointName("pdf", "pptx")).toBe("pdf-to-presentation");
|
||||
expect(getEndpointName("pdf", "odp")).toBe("pdf-to-presentation");
|
||||
|
||||
// PDF to Data formats
|
||||
expect(getEndpointName("pdf", "csv")).toBe("pdf-to-csv");
|
||||
expect(getEndpointName("pdf", "txt")).toBe("pdf-to-text");
|
||||
expect(getEndpointName("pdf", "rtf")).toBe("pdf-to-text");
|
||||
expect(getEndpointName("pdf", "md")).toBe("pdf-to-markdown");
|
||||
|
||||
// PDF to Web formats
|
||||
expect(getEndpointName("pdf", "html")).toBe("pdf-to-html");
|
||||
expect(getEndpointName("pdf", "xml")).toBe("pdf-to-xml");
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(getEndpointName("pdf", "pdfa")).toBe("pdf-to-pdfa");
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(getEndpointName("docx", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("doc", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("odt", "pdf")).toBe("file-to-pdf");
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(getEndpointName("xlsx", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("xls", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("ods", "pdf")).toBe("file-to-pdf");
|
||||
|
||||
// Presentations to PDF
|
||||
expect(getEndpointName("pptx", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("ppt", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("odp", "pdf")).toBe("file-to-pdf");
|
||||
|
||||
// Images to PDF
|
||||
expect(getEndpointName("jpg", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("jpeg", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("png", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("gif", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("bmp", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("tiff", "pdf")).toBe("img-to-pdf");
|
||||
expect(getEndpointName("webp", "pdf")).toBe("img-to-pdf");
|
||||
|
||||
// Web formats to PDF
|
||||
expect(getEndpointName("html", "pdf")).toBe("html-to-pdf");
|
||||
|
||||
// Markdown to PDF
|
||||
expect(getEndpointName("md", "pdf")).toBe("markdown-to-pdf");
|
||||
|
||||
// Text formats to PDF
|
||||
expect(getEndpointName("txt", "pdf")).toBe("file-to-pdf");
|
||||
expect(getEndpointName("rtf", "pdf")).toBe("file-to-pdf");
|
||||
|
||||
// Email to PDF (EML and MSG)
|
||||
expect(getEndpointName("eml", "pdf")).toBe("eml-to-pdf");
|
||||
expect(getEndpointName("msg", "pdf")).toBe("eml-to-pdf");
|
||||
});
|
||||
|
||||
test("should return empty string for unsupported conversions", () => {
|
||||
expect(getEndpointName("pdf", "exe")).toBe("");
|
||||
expect(getEndpointName("wav", "pdf")).toBe("file-to-pdf"); // Try using file to pdf as fallback
|
||||
expect(getEndpointName("png", "docx")).toBe(""); // Images can't convert to Word docs
|
||||
});
|
||||
|
||||
test("should handle empty or invalid inputs", () => {
|
||||
expect(getEndpointName("", "")).toBe("");
|
||||
expect(getEndpointName("pdf", "")).toBe("");
|
||||
expect(getEndpointName("", "pdf")).toBe("");
|
||||
expect(getEndpointName("nonexistent", "alsononexistent")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEndpointUrl", () => {
|
||||
test("should return correct endpoint URLs for all supported conversions", () => {
|
||||
// PDF to Image formats
|
||||
expect(getEndpointUrl("pdf", "png")).toBe("/api/v1/convert/pdf/img");
|
||||
expect(getEndpointUrl("pdf", "jpg")).toBe("/api/v1/convert/pdf/img");
|
||||
expect(getEndpointUrl("pdf", "gif")).toBe("/api/v1/convert/pdf/img");
|
||||
expect(getEndpointUrl("pdf", "tiff")).toBe("/api/v1/convert/pdf/img");
|
||||
expect(getEndpointUrl("pdf", "bmp")).toBe("/api/v1/convert/pdf/img");
|
||||
expect(getEndpointUrl("pdf", "webp")).toBe("/api/v1/convert/pdf/img");
|
||||
|
||||
// PDF to Office formats
|
||||
expect(getEndpointUrl("pdf", "docx")).toBe("/api/v1/convert/pdf/word");
|
||||
expect(getEndpointUrl("pdf", "odt")).toBe("/api/v1/convert/pdf/word");
|
||||
expect(getEndpointUrl("pdf", "pptx")).toBe(
|
||||
"/api/v1/convert/pdf/presentation",
|
||||
);
|
||||
expect(getEndpointUrl("pdf", "odp")).toBe(
|
||||
"/api/v1/convert/pdf/presentation",
|
||||
);
|
||||
|
||||
// PDF to Data formats
|
||||
expect(getEndpointUrl("pdf", "csv")).toBe("/api/v1/convert/pdf/csv");
|
||||
expect(getEndpointUrl("pdf", "txt")).toBe("/api/v1/convert/pdf/text");
|
||||
expect(getEndpointUrl("pdf", "rtf")).toBe("/api/v1/convert/pdf/text");
|
||||
expect(getEndpointUrl("pdf", "md")).toBe("/api/v1/convert/pdf/markdown");
|
||||
|
||||
// PDF to Web formats
|
||||
expect(getEndpointUrl("pdf", "html")).toBe("/api/v1/convert/pdf/html");
|
||||
expect(getEndpointUrl("pdf", "xml")).toBe("/api/v1/convert/pdf/xml");
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(getEndpointUrl("pdf", "pdfa")).toBe("/api/v1/convert/pdf/pdfa");
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(getEndpointUrl("docx", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("doc", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("odt", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(getEndpointUrl("xlsx", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("xls", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("ods", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
|
||||
// Presentations to PDF
|
||||
expect(getEndpointUrl("pptx", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("ppt", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("odp", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
|
||||
// Images to PDF
|
||||
expect(getEndpointUrl("jpg", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("jpeg", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("png", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("gif", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("bmp", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("tiff", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
expect(getEndpointUrl("webp", "pdf")).toBe("/api/v1/convert/img/pdf");
|
||||
|
||||
// Web formats to PDF
|
||||
expect(getEndpointUrl("html", "pdf")).toBe("/api/v1/convert/html/pdf");
|
||||
|
||||
// Markdown to PDF
|
||||
expect(getEndpointUrl("md", "pdf")).toBe("/api/v1/convert/markdown/pdf");
|
||||
|
||||
// Text formats to PDF
|
||||
expect(getEndpointUrl("txt", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
expect(getEndpointUrl("rtf", "pdf")).toBe("/api/v1/convert/file/pdf");
|
||||
|
||||
// Email to PDF (EML and MSG)
|
||||
expect(getEndpointUrl("eml", "pdf")).toBe("/api/v1/convert/eml/pdf");
|
||||
expect(getEndpointUrl("msg", "pdf")).toBe("/api/v1/convert/eml/pdf");
|
||||
});
|
||||
|
||||
test("should return empty string for unsupported conversions", () => {
|
||||
expect(getEndpointUrl("pdf", "exe")).toBe("");
|
||||
expect(getEndpointUrl("wav", "pdf")).toBe("/api/v1/convert/file/pdf"); // Try using file to pdf as fallback
|
||||
expect(getEndpointUrl("invalid", "invalid")).toBe("");
|
||||
});
|
||||
|
||||
test("should handle empty inputs", () => {
|
||||
expect(getEndpointUrl("", "")).toBe("");
|
||||
expect(getEndpointUrl("pdf", "")).toBe("");
|
||||
expect(getEndpointUrl("", "pdf")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConversionSupported", () => {
|
||||
test("should return true for all supported conversions", () => {
|
||||
// PDF to Image formats
|
||||
expect(isConversionSupported("pdf", "png")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "jpg")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "gif")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "tiff")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "bmp")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "webp")).toBe(true);
|
||||
|
||||
// PDF to Office formats
|
||||
expect(isConversionSupported("pdf", "docx")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "odt")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "pptx")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "odp")).toBe(true);
|
||||
|
||||
// PDF to Data formats
|
||||
expect(isConversionSupported("pdf", "csv")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "txt")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "rtf")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "md")).toBe(true);
|
||||
|
||||
// PDF to Web formats
|
||||
expect(isConversionSupported("pdf", "html")).toBe(true);
|
||||
expect(isConversionSupported("pdf", "xml")).toBe(true);
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(isConversionSupported("pdf", "pdfa")).toBe(true);
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(isConversionSupported("docx", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("doc", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("odt", "pdf")).toBe(true);
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(isConversionSupported("xlsx", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("xls", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("ods", "pdf")).toBe(true);
|
||||
|
||||
// Presentations to PDF
|
||||
expect(isConversionSupported("pptx", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("ppt", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("odp", "pdf")).toBe(true);
|
||||
|
||||
// Images to PDF
|
||||
expect(isConversionSupported("jpg", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("jpeg", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("png", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("gif", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("bmp", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("tiff", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("webp", "pdf")).toBe(true);
|
||||
|
||||
// Web formats to PDF
|
||||
expect(isConversionSupported("html", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("htm", "pdf")).toBe(true);
|
||||
|
||||
// Markdown to PDF
|
||||
expect(isConversionSupported("md", "pdf")).toBe(true);
|
||||
|
||||
// Text formats to PDF
|
||||
expect(isConversionSupported("txt", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("rtf", "pdf")).toBe(true);
|
||||
|
||||
// Email to PDF (EML and MSG)
|
||||
expect(isConversionSupported("eml", "pdf")).toBe(true);
|
||||
expect(isConversionSupported("msg", "pdf")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for unsupported conversions", () => {
|
||||
expect(isConversionSupported("pdf", "exe")).toBe(false);
|
||||
expect(isConversionSupported("wav", "pdf")).toBe(true); // Fallback to file to pdf
|
||||
expect(isConversionSupported("png", "docx")).toBe(false);
|
||||
expect(isConversionSupported("nonexistent", "alsononexistent")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle empty inputs", () => {
|
||||
expect(isConversionSupported("", "")).toBe(false);
|
||||
expect(isConversionSupported("pdf", "")).toBe(false);
|
||||
expect(isConversionSupported("", "pdf")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImageFormat", () => {
|
||||
test("should return true for image formats", () => {
|
||||
expect(isImageFormat("png")).toBe(true);
|
||||
expect(isImageFormat("jpg")).toBe(true);
|
||||
expect(isImageFormat("jpeg")).toBe(true);
|
||||
expect(isImageFormat("gif")).toBe(true);
|
||||
expect(isImageFormat("tiff")).toBe(true);
|
||||
expect(isImageFormat("bmp")).toBe(true);
|
||||
expect(isImageFormat("webp")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-image formats", () => {
|
||||
expect(isImageFormat("pdf")).toBe(false);
|
||||
expect(isImageFormat("docx")).toBe(false);
|
||||
expect(isImageFormat("txt")).toBe(false);
|
||||
expect(isImageFormat("csv")).toBe(false);
|
||||
expect(isImageFormat("html")).toBe(false);
|
||||
expect(isImageFormat("xml")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle case insensitivity", () => {
|
||||
expect(isImageFormat("PNG")).toBe(true);
|
||||
expect(isImageFormat("JPG")).toBe(true);
|
||||
expect(isImageFormat("JPEG")).toBe(true);
|
||||
expect(isImageFormat("Png")).toBe(true);
|
||||
expect(isImageFormat("JpG")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle empty and invalid inputs", () => {
|
||||
expect(isImageFormat("")).toBe(false);
|
||||
expect(isImageFormat("invalid")).toBe(false);
|
||||
expect(isImageFormat("123")).toBe(false);
|
||||
expect(isImageFormat(".")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle mixed case and edge cases", () => {
|
||||
expect(isImageFormat("webP")).toBe(true);
|
||||
expect(isImageFormat("WEBP")).toBe(true);
|
||||
expect(isImageFormat("tIFf")).toBe(true);
|
||||
expect(isImageFormat("bMp")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle null and undefined inputs gracefully", () => {
|
||||
// Note: TypeScript prevents these, but test runtime behavior for robustness
|
||||
// The current implementation handles these gracefully by returning falsy values
|
||||
expect(getEndpointName(null as any, null as any)).toBe("");
|
||||
expect(getEndpointUrl(undefined as any, undefined as any)).toBe("");
|
||||
expect(isConversionSupported(null as any, null as any)).toBe(false);
|
||||
|
||||
// isImageFormat will throw because it calls toLowerCase() on null/undefined
|
||||
expect(() => isImageFormat(null as any)).toThrow();
|
||||
expect(() => isImageFormat(undefined as any)).toThrow();
|
||||
});
|
||||
|
||||
test("should handle special characters in file extensions", () => {
|
||||
expect(isImageFormat("png@")).toBe(false);
|
||||
expect(isImageFormat("jpg#")).toBe(false);
|
||||
expect(isImageFormat("png.")).toBe(false);
|
||||
expect(getEndpointName("pdf@", "png")).toBe("");
|
||||
expect(getEndpointName("pdf", "png#")).toBe("");
|
||||
});
|
||||
|
||||
test("should handle very long extension names", () => {
|
||||
const longExtension = "a".repeat(100);
|
||||
expect(isImageFormat(longExtension)).toBe(false);
|
||||
expect(getEndpointName("pdf", longExtension)).toBe("");
|
||||
expect(getEndpointName(longExtension, "pdf")).toBe("file-to-pdf"); // Fallback to file to pdf
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
CONVERSION_MATRIX,
|
||||
TO_FORMAT_OPTIONS,
|
||||
} from "@app/constants/convertConstants";
|
||||
|
||||
/**
|
||||
* Resolves the endpoint name for a given conversion
|
||||
*/
|
||||
export const getEndpointName = (
|
||||
fromExtension: string,
|
||||
toExtension: string,
|
||||
): string => {
|
||||
if (!fromExtension || !toExtension) return "";
|
||||
|
||||
let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
|
||||
|
||||
// If no explicit mapping exists and we're converting to PDF,
|
||||
// fall back to 'any' which uses file-to-pdf endpoint
|
||||
if (!endpointKey && toExtension === "pdf" && fromExtension !== "any") {
|
||||
endpointKey = EXTENSION_TO_ENDPOINT["any"]?.[toExtension];
|
||||
}
|
||||
|
||||
return endpointKey || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the full endpoint URL for a given conversion
|
||||
*/
|
||||
export const getEndpointUrl = (
|
||||
fromExtension: string,
|
||||
toExtension: string,
|
||||
): string => {
|
||||
const endpointName = getEndpointName(fromExtension, toExtension);
|
||||
if (!endpointName) return "";
|
||||
|
||||
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
|
||||
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
|
||||
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a conversion is supported
|
||||
*/
|
||||
export const isConversionSupported = (
|
||||
fromExtension: string,
|
||||
toExtension: string,
|
||||
): boolean => {
|
||||
return getEndpointName(fromExtension, toExtension) !== "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is an image format
|
||||
*/
|
||||
export const isImageFormat = (extension: string): boolean => {
|
||||
return ["png", "jpg", "jpeg", "gif", "tiff", "bmp", "webp"].includes(
|
||||
extension.toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export const isSvgFormat = (extension: string): boolean => {
|
||||
return extension.toLowerCase() === "svg";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is a web format
|
||||
*/
|
||||
export const isWebFormat = (extension: string): boolean => {
|
||||
return ["html", "zip"].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is an office format (Word, Excel, PowerPoint, OpenOffice)
|
||||
* These formats use LibreOffice for conversion and require individual file processing
|
||||
*/
|
||||
export const isOfficeFormat = (extension: string): boolean => {
|
||||
return [
|
||||
"docx",
|
||||
"doc",
|
||||
"odt", // Word processors
|
||||
"xlsx",
|
||||
"xls",
|
||||
"ods", // Spreadsheets
|
||||
"pptx",
|
||||
"ppt",
|
||||
"odp", // Presentations
|
||||
].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets available target extensions for a given source extension
|
||||
* Extracted from useConvertParameters to be reusable in automation settings
|
||||
*/
|
||||
export const getAvailableToExtensions = (
|
||||
fromExtension: string,
|
||||
): Array<{ value: string; label: string; group: string }> => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith("file-")) {
|
||||
// Dynamic format - use 'any' conversion options (file-to-pdf)
|
||||
const supportedExtensions = CONVERSION_MATRIX["any"] || [];
|
||||
return TO_FORMAT_OPTIONS.filter((option) =>
|
||||
supportedExtensions.includes(option.value),
|
||||
);
|
||||
}
|
||||
|
||||
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
|
||||
if (supportedExtensions.length === 0 && fromExtension !== "any") {
|
||||
supportedExtensions = CONVERSION_MATRIX["any"] || [];
|
||||
}
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter((option) =>
|
||||
supportedExtensions.includes(option.value),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Utility functions for crop coordinate conversion and PDF bounds handling
|
||||
*/
|
||||
|
||||
export interface PDFBounds {
|
||||
/** PDF width in points (actual PDF dimensions) */
|
||||
actualWidth: number;
|
||||
/** PDF height in points (actual PDF dimensions) */
|
||||
actualHeight: number;
|
||||
/** Thumbnail display width in pixels */
|
||||
thumbnailWidth: number;
|
||||
/** Thumbnail display height in pixels */
|
||||
thumbnailHeight: number;
|
||||
/** Horizontal offset for centering thumbnail in container */
|
||||
offsetX: number;
|
||||
/** Vertical offset for centering thumbnail in container */
|
||||
offsetY: number;
|
||||
/** Scale factor: thumbnailSize / actualSize */
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface Rectangle {
|
||||
/** X coordinate */
|
||||
x: number;
|
||||
/** Y coordinate */
|
||||
y: number;
|
||||
/** Width */
|
||||
width: number;
|
||||
/** Height */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Runtime type guard */
|
||||
export function isRectangle(value: unknown): value is Rectangle {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
|
||||
const r = value as Record<string, unknown>;
|
||||
const isNum = (n: unknown): n is number =>
|
||||
typeof n === "number" && Number.isFinite(n);
|
||||
|
||||
return (
|
||||
isNum(r.x) &&
|
||||
isNum(r.y) &&
|
||||
isNum(r.width) &&
|
||||
isNum(r.height) &&
|
||||
r.width >= 0 &&
|
||||
r.height >= 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate PDF bounds for coordinate conversion based on thumbnail dimensions
|
||||
*/
|
||||
export const calculatePDFBounds = (
|
||||
actualPDFWidth: number,
|
||||
actualPDFHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): PDFBounds => {
|
||||
// Calculate scale to fit PDF within container while maintaining aspect ratio
|
||||
const scaleX = containerWidth / actualPDFWidth;
|
||||
const scaleY = containerHeight / actualPDFHeight;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// Calculate actual thumbnail display size
|
||||
const thumbnailWidth = actualPDFWidth * scale;
|
||||
const thumbnailHeight = actualPDFHeight * scale;
|
||||
|
||||
// Calculate centering offsets - these represent where the thumbnail is positioned within the container
|
||||
const offsetX = (containerWidth - thumbnailWidth) / 2;
|
||||
const offsetY = (containerHeight - thumbnailHeight) / 2;
|
||||
|
||||
return {
|
||||
actualWidth: actualPDFWidth,
|
||||
actualHeight: actualPDFHeight,
|
||||
thumbnailWidth,
|
||||
thumbnailHeight,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert DOM coordinates (relative to container) to PDF coordinates
|
||||
* Handles coordinate system conversion (DOM uses top-left, PDF uses bottom-left origin)
|
||||
*/
|
||||
export const domToPDFCoordinates = (
|
||||
domRect: Rectangle,
|
||||
pdfBounds: PDFBounds,
|
||||
): Rectangle => {
|
||||
// Convert DOM coordinates to thumbnail-relative coordinates
|
||||
const thumbX = domRect.x - pdfBounds.offsetX;
|
||||
const thumbY = domRect.y - pdfBounds.offsetY;
|
||||
|
||||
// Convert to PDF coordinates (scale and flip Y-axis)
|
||||
const pdfX = thumbX / pdfBounds.scale;
|
||||
const pdfY =
|
||||
pdfBounds.actualHeight - (thumbY + domRect.height) / pdfBounds.scale;
|
||||
const pdfWidth = domRect.width / pdfBounds.scale;
|
||||
const pdfHeight = domRect.height / pdfBounds.scale;
|
||||
|
||||
return {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: pdfWidth,
|
||||
height: pdfHeight,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert PDF coordinates to DOM coordinates (relative to container)
|
||||
*/
|
||||
export const pdfToDOMCoordinates = (
|
||||
cropArea: Rectangle,
|
||||
pdfBounds: PDFBounds,
|
||||
): Rectangle => {
|
||||
// Convert PDF coordinates to thumbnail coordinates (scale and flip Y-axis)
|
||||
const thumbX = cropArea.x * pdfBounds.scale;
|
||||
const thumbY =
|
||||
(pdfBounds.actualHeight - cropArea.y - cropArea.height) * pdfBounds.scale;
|
||||
const thumbWidth = cropArea.width * pdfBounds.scale;
|
||||
const thumbHeight = cropArea.height * pdfBounds.scale;
|
||||
|
||||
// Add container offsets to get DOM coordinates
|
||||
return {
|
||||
x: thumbX + pdfBounds.offsetX,
|
||||
y: thumbY + pdfBounds.offsetY,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrain a crop area to stay within PDF bounds
|
||||
*/
|
||||
export const constrainCropAreaToPDF = (
|
||||
cropArea: Rectangle,
|
||||
pdfBounds: PDFBounds,
|
||||
): Rectangle => {
|
||||
// Ensure crop area doesn't extend beyond PDF boundaries
|
||||
const maxX = Math.max(0, pdfBounds.actualWidth - cropArea.width);
|
||||
const maxY = Math.max(0, pdfBounds.actualHeight - cropArea.height);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(cropArea.x, maxX)),
|
||||
y: Math.max(0, Math.min(cropArea.y, maxY)),
|
||||
width: Math.min(
|
||||
cropArea.width,
|
||||
pdfBounds.actualWidth - Math.max(0, cropArea.x),
|
||||
),
|
||||
height: Math.min(
|
||||
cropArea.height,
|
||||
pdfBounds.actualHeight - Math.max(0, cropArea.y),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrain DOM coordinates to stay within thumbnail bounds
|
||||
*/
|
||||
export const constrainDOMRectToThumbnail = (
|
||||
domRect: Rectangle,
|
||||
pdfBounds: PDFBounds,
|
||||
): Rectangle => {
|
||||
const thumbnailLeft = pdfBounds.offsetX;
|
||||
const thumbnailTop = pdfBounds.offsetY;
|
||||
const thumbnailRight = pdfBounds.offsetX + pdfBounds.thumbnailWidth;
|
||||
const thumbnailBottom = pdfBounds.offsetY + pdfBounds.thumbnailHeight;
|
||||
|
||||
// Constrain position
|
||||
const maxX = Math.max(thumbnailLeft, thumbnailRight - domRect.width);
|
||||
const maxY = Math.max(thumbnailTop, thumbnailBottom - domRect.height);
|
||||
|
||||
const constrainedX = Math.max(thumbnailLeft, Math.min(domRect.x, maxX));
|
||||
const constrainedY = Math.max(thumbnailTop, Math.min(domRect.y, maxY));
|
||||
|
||||
// Constrain size to fit within thumbnail bounds from current position
|
||||
const maxWidth = thumbnailRight - constrainedX;
|
||||
const maxHeight = thumbnailBottom - constrainedY;
|
||||
|
||||
return {
|
||||
x: constrainedX,
|
||||
y: constrainedY,
|
||||
width: Math.min(domRect.width, maxWidth),
|
||||
height: Math.min(domRect.height, maxHeight),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a point is within the thumbnail area (not just the container)
|
||||
*/
|
||||
export const isPointInThumbnail = (
|
||||
x: number,
|
||||
y: number,
|
||||
pdfBounds: PDFBounds,
|
||||
): boolean => {
|
||||
return (
|
||||
x >= pdfBounds.offsetX &&
|
||||
x <= pdfBounds.offsetX + pdfBounds.thumbnailWidth &&
|
||||
y >= pdfBounds.offsetY &&
|
||||
y <= pdfBounds.offsetY + pdfBounds.thumbnailHeight
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a default crop area that covers the entire PDF
|
||||
*/
|
||||
export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfBounds.actualWidth,
|
||||
height: pdfBounds.actualHeight,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Round crop coordinates to reasonable precision (0.1 point)
|
||||
*/
|
||||
export const roundCropArea = (cropArea: Rectangle): Rectangle => {
|
||||
return {
|
||||
x: Math.round(cropArea.x * 10) / 10,
|
||||
y: Math.round(cropArea.y * 10) / 10,
|
||||
width: Math.round(cropArea.width * 10) / 10,
|
||||
height: Math.round(cropArea.height * 10) / 10,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
/**
|
||||
* Downloads a blob as a file using browser download API
|
||||
* @param blob - The blob to download
|
||||
* @param filename - The filename for the download
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
void downloadFile({ data: blob, filename });
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a single file from IndexedDB storage
|
||||
* @param file - The file object with storage information
|
||||
* @throws Error if file cannot be retrieved from storage
|
||||
*/
|
||||
export async function downloadFileFromStorage(
|
||||
file: StirlingFileStub,
|
||||
): Promise<void> {
|
||||
const lookupKey = file.id;
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (!stirlingFile) {
|
||||
throw new Error(`File "${file.name}" not found in storage`);
|
||||
}
|
||||
|
||||
await downloadFile({
|
||||
data: stirlingFile,
|
||||
filename: stirlingFile.name,
|
||||
localPath: file.localFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads multiple files as individual downloads
|
||||
* @param files - Array of files to download
|
||||
*/
|
||||
export async function downloadMultipleFiles(
|
||||
files: StirlingFileStub[],
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
await downloadFileFromStorage(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads multiple files as a single ZIP archive
|
||||
* @param files - Array of files to include in ZIP
|
||||
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
|
||||
*/
|
||||
export async function downloadFilesAsZip(
|
||||
files: StirlingFileStub[],
|
||||
zipFilename?: string,
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files provided for ZIP download");
|
||||
}
|
||||
|
||||
// Convert stored files to File objects
|
||||
const filesToZip: File[] = [];
|
||||
for (const fileWithUrl of files) {
|
||||
const lookupKey = fileWithUrl.id;
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (stirlingFile) {
|
||||
// StirlingFile is already a File object!
|
||||
filesToZip.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToZip.length === 0) {
|
||||
throw new Error("No valid files found in storage for ZIP download");
|
||||
}
|
||||
|
||||
// Generate default filename if not provided
|
||||
const finalZipFilename =
|
||||
zipFilename ||
|
||||
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, "")}.zip`;
|
||||
|
||||
// Create and download ZIP
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(
|
||||
filesToZip,
|
||||
finalZipFilename,
|
||||
);
|
||||
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart download function that handles single or multiple files appropriately
|
||||
* - Single file: Downloads directly
|
||||
* - Multiple files: Downloads as ZIP
|
||||
* @param files - Array of files to download
|
||||
* @param options - Download options
|
||||
*/
|
||||
export async function downloadFiles(
|
||||
files: StirlingFileStub[],
|
||||
options: {
|
||||
forceZip?: boolean;
|
||||
zipFilename?: string;
|
||||
multipleAsIndividual?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files provided for download");
|
||||
}
|
||||
|
||||
if (files.length === 1 && !options.forceZip) {
|
||||
// Single file download
|
||||
await downloadFileFromStorage(files[0]);
|
||||
} else if (options.multipleAsIndividual) {
|
||||
// Multiple individual downloads
|
||||
await downloadMultipleFiles(files);
|
||||
} else {
|
||||
// ZIP download (default for multiple files)
|
||||
await downloadFilesAsZip(files, options.zipFilename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a File object directly (for files already in memory)
|
||||
* @param file - The File object to download
|
||||
* @param filename - Optional custom filename
|
||||
*/
|
||||
export function downloadFileObject(file: File, filename?: string): void {
|
||||
void downloadFile({ data: file, filename: filename || file.name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads text content as a file
|
||||
* @param content - Text content to download
|
||||
* @param filename - Filename for the download
|
||||
* @param mimeType - MIME type (defaults to text/plain)
|
||||
*/
|
||||
export function downloadTextAsFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string = "text/plain",
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
void downloadFile({ data: blob, filename });
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads JSON data as a file
|
||||
* @param data - Data to serialize and download
|
||||
* @param filename - Filename for the download
|
||||
*/
|
||||
export function downloadJsonAsFile(data: any, filename: string): void {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
downloadTextAsFile(content, filename, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BookmarkPayload {
|
||||
title: string;
|
||||
pageNumber: number;
|
||||
children?: BookmarkPayload[];
|
||||
}
|
||||
|
||||
export interface BookmarkNode {
|
||||
id: string;
|
||||
title: string;
|
||||
pageNumber: number;
|
||||
children: BookmarkNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
const createBookmarkId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
export const createBookmarkNode = (
|
||||
bookmark?: Partial<BookmarkNode>,
|
||||
): BookmarkNode => ({
|
||||
id: bookmark?.id ?? createBookmarkId(),
|
||||
title: bookmark?.title ?? "",
|
||||
pageNumber: bookmark?.pageNumber ?? 1,
|
||||
children: bookmark?.children
|
||||
? bookmark.children.map((child) => createBookmarkNode(child))
|
||||
: [],
|
||||
expanded: bookmark?.expanded ?? true,
|
||||
});
|
||||
|
||||
export const hydrateBookmarkPayload = (
|
||||
payload: BookmarkPayload[] = [],
|
||||
): BookmarkNode[] => {
|
||||
return payload.map((item) => ({
|
||||
id: createBookmarkId(),
|
||||
title: item.title ?? "",
|
||||
pageNumber:
|
||||
typeof item.pageNumber === "number" && item.pageNumber > 0
|
||||
? item.pageNumber
|
||||
: 1,
|
||||
expanded: true,
|
||||
children: item.children ? hydrateBookmarkPayload(item.children) : [],
|
||||
}));
|
||||
};
|
||||
|
||||
export const serializeBookmarkNodes = (
|
||||
bookmarks: BookmarkNode[],
|
||||
): BookmarkPayload[] => {
|
||||
return bookmarks.map((bookmark) => ({
|
||||
title: bookmark.title,
|
||||
pageNumber: bookmark.pageNumber,
|
||||
children: serializeBookmarkNodes(bookmark.children),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export function getDocumentFileDialogFilter() {
|
||||
return [
|
||||
{
|
||||
name: "Documents",
|
||||
extensions: [
|
||||
"pdf",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"html",
|
||||
"zip",
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* File hashing utilities for cache key generation
|
||||
*/
|
||||
|
||||
export class FileHasher {
|
||||
private static readonly CHUNK_SIZE = 64 * 1024; // 64KB chunks for hashing
|
||||
|
||||
/**
|
||||
* Generate a content-based hash for a file
|
||||
* Uses first + last + middle chunks to create a reasonably unique hash
|
||||
* without reading the entire file (which would be expensive for large files)
|
||||
*/
|
||||
static async generateContentHash(file: File): Promise<string> {
|
||||
const chunks = await this.getFileChunks(file);
|
||||
const combined = await this.combineChunks(chunks);
|
||||
return await this.hashArrayBuffer(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fast hash based on file metadata
|
||||
* Faster but less collision-resistant than content hash
|
||||
*/
|
||||
static generateMetadataHash(file: File): string {
|
||||
const data = `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
|
||||
return this.simpleHash(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hybrid hash that balances speed and uniqueness
|
||||
* Uses metadata + small content sample
|
||||
*/
|
||||
static async generateHybridHash(file: File): Promise<string> {
|
||||
const metadataHash = this.generateMetadataHash(file);
|
||||
|
||||
// For small files, use full content hash
|
||||
if (file.size <= 1024 * 1024) {
|
||||
// 1MB
|
||||
const contentHash = await this.generateContentHash(file);
|
||||
return `${metadataHash}-${contentHash}`;
|
||||
}
|
||||
|
||||
// For large files, use first chunk only
|
||||
const firstChunk = file.slice(0, this.CHUNK_SIZE);
|
||||
const firstChunkBuffer = await firstChunk.arrayBuffer();
|
||||
const firstChunkHash = await this.hashArrayBuffer(firstChunkBuffer);
|
||||
|
||||
return `${metadataHash}-${firstChunkHash}`;
|
||||
}
|
||||
|
||||
private static async getFileChunks(file: File): Promise<ArrayBuffer[]> {
|
||||
const chunks: ArrayBuffer[] = [];
|
||||
|
||||
// First chunk
|
||||
if (file.size > 0) {
|
||||
const firstChunk = file.slice(0, Math.min(this.CHUNK_SIZE, file.size));
|
||||
chunks.push(await firstChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
// Middle chunk (if file is large enough)
|
||||
if (file.size > this.CHUNK_SIZE * 2) {
|
||||
const middleStart =
|
||||
Math.floor(file.size / 2) - Math.floor(this.CHUNK_SIZE / 2);
|
||||
const middleEnd = middleStart + this.CHUNK_SIZE;
|
||||
const middleChunk = file.slice(middleStart, middleEnd);
|
||||
chunks.push(await middleChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
// Last chunk (if file is large enough and different from first)
|
||||
if (file.size > this.CHUNK_SIZE) {
|
||||
const lastStart = Math.max(file.size - this.CHUNK_SIZE, this.CHUNK_SIZE);
|
||||
const lastChunk = file.slice(lastStart);
|
||||
chunks.push(await lastChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static async combineChunks(
|
||||
chunks: ArrayBuffer[],
|
||||
): Promise<ArrayBuffer> {
|
||||
const totalLength = chunks.reduce(
|
||||
(sum, chunk) => sum + chunk.byteLength,
|
||||
0,
|
||||
);
|
||||
const combined = new Uint8Array(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return combined.buffer;
|
||||
}
|
||||
|
||||
private static async hashArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API for proper hashing
|
||||
if (crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// Fallback for environments without crypto.subtle
|
||||
return this.simpleHash(Array.from(new Uint8Array(buffer)).join(""));
|
||||
}
|
||||
|
||||
private static simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash.toString();
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file matches its expected hash
|
||||
* Useful for detecting file corruption or changes
|
||||
*/
|
||||
static async validateFileHash(
|
||||
file: File,
|
||||
expectedHash: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const actualHash = await this.generateHybridHash(file);
|
||||
return actualHash === expectedHash;
|
||||
} catch (error) {
|
||||
console.error("Hash validation failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* File History Utilities
|
||||
*
|
||||
* Helper functions for IndexedDB-based file history management.
|
||||
* Handles file history operations and lineage tracking.
|
||||
*/
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
/**
|
||||
* Group files by processing branches - each branch ends in a leaf file
|
||||
* Returns Map<fileId, lineagePath[]> where fileId is the leaf and lineagePath is the path back to original
|
||||
*/
|
||||
export function groupFilesByOriginal(
|
||||
StirlingFileStubs: StirlingFileStub[],
|
||||
): Map<string, StirlingFileStub[]> {
|
||||
const groups = new Map<string, StirlingFileStub[]>();
|
||||
|
||||
// Create a map for quick lookups
|
||||
const fileMap = new Map<string, StirlingFileStub>();
|
||||
for (const record of StirlingFileStubs) {
|
||||
fileMap.set(record.id, record);
|
||||
}
|
||||
|
||||
// Find leaf files (files that are not parents of any other files AND have version history)
|
||||
// Original files (v0) should only be leaves if they have no processed versions at all
|
||||
const leafFiles = StirlingFileStubs.filter((stub) => {
|
||||
const isParentOfOthers = StirlingFileStubs.some(
|
||||
(otherStub) => otherStub.parentFileId === stub.id,
|
||||
);
|
||||
const isOriginalOfOthers = StirlingFileStubs.some(
|
||||
(otherStub) => otherStub.originalFileId === stub.id,
|
||||
);
|
||||
|
||||
// A file is a leaf if:
|
||||
// 1. It's not a parent of any other files, AND
|
||||
// 2. It has processing history (versionNumber > 0) OR it's not referenced as original by others
|
||||
return (
|
||||
!isParentOfOthers &&
|
||||
((stub.versionNumber && stub.versionNumber > 0) || !isOriginalOfOthers)
|
||||
);
|
||||
});
|
||||
|
||||
// For each leaf file, build its complete lineage path back to original
|
||||
for (const leafFile of leafFiles) {
|
||||
const lineagePath: StirlingFileStub[] = [];
|
||||
let currentFile: StirlingFileStub | undefined = leafFile;
|
||||
|
||||
// Trace back through parentFileId chain to build this specific branch
|
||||
while (currentFile) {
|
||||
lineagePath.push(currentFile);
|
||||
|
||||
// Move to parent file in this branch
|
||||
let nextFile: StirlingFileStub | undefined = undefined;
|
||||
|
||||
if (currentFile.parentFileId) {
|
||||
nextFile = fileMap.get(currentFile.parentFileId);
|
||||
} else if (
|
||||
currentFile.originalFileId &&
|
||||
currentFile.originalFileId !== currentFile.id
|
||||
) {
|
||||
// For v1 files, the original file might be referenced by originalFileId
|
||||
nextFile = fileMap.get(currentFile.originalFileId);
|
||||
}
|
||||
|
||||
// Check for infinite loops before moving to next
|
||||
if (nextFile && lineagePath.some((file) => file.id === nextFile!.id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentFile = nextFile;
|
||||
}
|
||||
|
||||
// Sort lineage with latest version first (leaf at top)
|
||||
lineagePath.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
|
||||
|
||||
// Use leaf file ID as the group key - each branch gets its own group
|
||||
groups.set(leafFile.id, lineagePath);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file has version history
|
||||
*/
|
||||
export function hasVersionHistory(fileStub: StirlingFileStub): boolean {
|
||||
return !!(
|
||||
fileStub.originalFileId &&
|
||||
fileStub.versionNumber &&
|
||||
fileStub.versionNumber > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Runtime validation utilities for FileId safety
|
||||
*/
|
||||
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
|
||||
// Validate that a string is a proper FileId (has UUID format)
|
||||
export function isValidFileId(id: string): id is FileId {
|
||||
// Check UUID v4 format: 8-4-4-4-12 hex digits
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Unit tests for file response utility functions
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
getFilenameFromHeaders,
|
||||
createFileFromApiResponse,
|
||||
} from "@app/utils/fileResponseUtils";
|
||||
|
||||
describe("fileResponseUtils", () => {
|
||||
describe("getFilenameFromHeaders", () => {
|
||||
test("should extract filename from content-disposition header", () => {
|
||||
const contentDisposition = 'attachment; filename="document.pdf"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe("document.pdf");
|
||||
});
|
||||
|
||||
test("should extract filename without quotes", () => {
|
||||
const contentDisposition = "attachment; filename=document.pdf";
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe("document.pdf");
|
||||
});
|
||||
|
||||
test("should handle single quotes", () => {
|
||||
const contentDisposition = "attachment; filename='document.pdf'";
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe("document.pdf");
|
||||
});
|
||||
|
||||
test("should return null for malformed header", () => {
|
||||
const contentDisposition = "attachment; invalid=format";
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null for empty header", () => {
|
||||
const filename = getFilenameFromHeaders("");
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null for undefined header", () => {
|
||||
const filename = getFilenameFromHeaders();
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle complex filenames with spaces and special chars", () => {
|
||||
const contentDisposition = 'attachment; filename="My Document (1).pdf"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe("My Document (1).pdf");
|
||||
});
|
||||
|
||||
test("should handle filename with extension when downloadHtml is enabled", () => {
|
||||
const contentDisposition = 'attachment; filename="email_content.html"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe("email_content.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFileFromApiResponse", () => {
|
||||
test("should create file using header filename when available", () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition": 'attachment; filename="server_filename.pdf"',
|
||||
};
|
||||
const fallbackFilename = "fallback.pdf";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("server_filename.pdf");
|
||||
expect(file.type).toBe("application/pdf");
|
||||
expect(file.size).toBe(4);
|
||||
});
|
||||
|
||||
test("should use fallback filename when no header filename", () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {
|
||||
"content-type": "application/pdf",
|
||||
};
|
||||
const fallbackFilename = "converted_file.pdf";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("converted_file.pdf");
|
||||
expect(file.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
test("should handle HTML response when downloadHtml is enabled", () => {
|
||||
const responseData = "<html><body>Test</body></html>";
|
||||
const headers = {
|
||||
"content-type": "text/html",
|
||||
"content-disposition": 'attachment; filename="email_content.html"',
|
||||
};
|
||||
const fallbackFilename = "fallback.pdf";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("email_content.html");
|
||||
expect(file.type).toBe("text/html");
|
||||
});
|
||||
|
||||
test("should handle ZIP response", () => {
|
||||
const responseData = new Uint8Array([80, 75, 3, 4]); // ZIP file signature
|
||||
const headers = {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition": 'attachment; filename="converted_files.zip"',
|
||||
};
|
||||
const fallbackFilename = "fallback.pdf";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("converted_files.zip");
|
||||
expect(file.type).toBe("application/zip");
|
||||
});
|
||||
|
||||
test("should use default content-type when none provided", () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {};
|
||||
const fallbackFilename = "test.bin";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("test.bin");
|
||||
expect(file.type).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
test("should handle null/undefined headers gracefully", () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = null;
|
||||
const fallbackFilename = "test.bin";
|
||||
|
||||
const file = createFileFromApiResponse(
|
||||
responseData,
|
||||
headers,
|
||||
fallbackFilename,
|
||||
);
|
||||
|
||||
expect(file.name).toBe("test.bin");
|
||||
expect(file.type).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Generic utility functions for handling file responses from API endpoints
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts filename from Content-Disposition header
|
||||
* @param contentDisposition - Content-Disposition header value
|
||||
* @returns Filename if found, null otherwise
|
||||
*/
|
||||
export const getFilenameFromHeaders = (
|
||||
contentDisposition: string = "",
|
||||
): string | null => {
|
||||
const match = contentDisposition.match(
|
||||
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
|
||||
);
|
||||
if (match && match[1]) {
|
||||
const filename = match[1].replace(/['"]/g, "");
|
||||
|
||||
// Decode URL-encoded characters (e.g., %20 -> space)
|
||||
try {
|
||||
return decodeURIComponent(filename);
|
||||
} catch {
|
||||
// If decoding fails, return the original filename
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a File object from API response using the filename from headers
|
||||
* @param responseData - The response data (blob/arraybuffer/string)
|
||||
* @param headers - Response headers object
|
||||
* @param fallbackFilename - Filename to use if none provided in headers
|
||||
* @returns File object
|
||||
*/
|
||||
export const createFileFromApiResponse = (
|
||||
responseData: any,
|
||||
headers: any,
|
||||
fallbackFilename: string,
|
||||
): File => {
|
||||
const contentType = headers?.["content-type"] || "application/octet-stream";
|
||||
const contentDisposition = headers?.["content-disposition"] || "";
|
||||
|
||||
const filename =
|
||||
getFilenameFromHeaders(contentDisposition) || fallbackFilename;
|
||||
const blob = new Blob([responseData], { type: contentType });
|
||||
|
||||
return new File([blob], filename, { type: contentType });
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isPdfFile,
|
||||
detectFileExtension,
|
||||
formatFileSize,
|
||||
} from "@app/utils/fileUtils";
|
||||
|
||||
describe("fileUtils", () => {
|
||||
describe("isPdfFile", () => {
|
||||
it("should return true for PDF files with correct MIME type", () => {
|
||||
const pdfFile = new File(["content"], "document.pdf", {
|
||||
type: "application/pdf",
|
||||
});
|
||||
expect(isPdfFile(pdfFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for PDF files with .pdf extension even without MIME type", () => {
|
||||
const pdfFile = new File(["content"], "document.pdf", { type: "" });
|
||||
expect(isPdfFile(pdfFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-PDF files", () => {
|
||||
const txtFile = new File(["content"], "document.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(isPdfFile(txtFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for image files", () => {
|
||||
const imageFile = new File(["content"], "image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
expect(isPdfFile(imageFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for null", () => {
|
||||
expect(isPdfFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for undefined", () => {
|
||||
expect(isPdfFile(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle file-like objects with name and type", () => {
|
||||
const fileLike = { name: "test.pdf", type: "application/pdf" };
|
||||
expect(isPdfFile(fileLike)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle file-like objects with PDF extension but no type", () => {
|
||||
const fileLike = { name: "test.pdf", type: "" };
|
||||
expect(isPdfFile(fileLike)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectFileExtension", () => {
|
||||
it("should detect PDF extension", () => {
|
||||
expect(detectFileExtension("document.pdf")).toBe("pdf");
|
||||
});
|
||||
|
||||
it("should detect extension in uppercase", () => {
|
||||
expect(detectFileExtension("document.PDF")).toBe("pdf");
|
||||
});
|
||||
|
||||
it("should return empty string for files without extension", () => {
|
||||
expect(detectFileExtension("document")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle multiple dots in filename", () => {
|
||||
expect(detectFileExtension("my.document.pdf")).toBe("pdf");
|
||||
});
|
||||
|
||||
it("should normalize jpeg to jpg", () => {
|
||||
expect(detectFileExtension("image.jpeg")).toBe("jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("should format bytes", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
expect(formatFileSize(500)).toBe("500 B");
|
||||
});
|
||||
|
||||
it("should format kilobytes", () => {
|
||||
expect(formatFileSize(1024)).toBe("1 KB");
|
||||
expect(formatFileSize(2048)).toBe("2 KB");
|
||||
});
|
||||
|
||||
it("should format megabytes", () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1 MB");
|
||||
expect(formatFileSize(5 * 1024 * 1024)).toBe("5 MB");
|
||||
});
|
||||
|
||||
it("should format gigabytes", () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// Pure utility functions for file operations
|
||||
|
||||
/**
|
||||
* Consolidated file size formatting utility
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file date as string
|
||||
*/
|
||||
export function getFileDate(file: File | { lastModified: number }): string {
|
||||
if (file.lastModified) {
|
||||
return new Date(file.lastModified).toLocaleString();
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size as string (legacy method for backward compatibility)
|
||||
*/
|
||||
export function getFileSize(file: File | { size: number }): string {
|
||||
if (!file.size) return "Unknown";
|
||||
return formatFileSize(file.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and normalizes file extension from filename
|
||||
* @param filename - The filename to extract extension from
|
||||
* @returns Normalized file extension in lowercase, empty string if no extension
|
||||
*/
|
||||
export function detectFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== "string") return "";
|
||||
|
||||
const parts = filename.split(".");
|
||||
// If there's no extension (no dots or only one part), return empty string
|
||||
if (parts.length <= 1) return "";
|
||||
|
||||
// Get the last part (extension) in lowercase
|
||||
let extension = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
// Normalize common extension variants
|
||||
if (extension === "jpeg") extension = "jpg";
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the file extension from a filename
|
||||
* @param filename - The filename to process
|
||||
* @param options - Options for processing
|
||||
* @param options.preserveCase - If true, preserves original case. If false (default), converts to lowercase
|
||||
* @returns Filename without extension
|
||||
* @example
|
||||
* getFilenameWithoutExtension('document.pdf') // 'document'
|
||||
* getFilenameWithoutExtension('my.file.name.txt') // 'my.file.name'
|
||||
* getFilenameWithoutExtension('REPORT.PDF', { preserveCase: true }) // 'REPORT'
|
||||
*/
|
||||
export function getFilenameWithoutExtension(
|
||||
filename: string,
|
||||
options: { preserveCase?: boolean } = {},
|
||||
): string {
|
||||
if (!filename || typeof filename !== "string") return "";
|
||||
|
||||
const { preserveCase = false } = options;
|
||||
const withoutExtension = filename.replace(/\.[^.]+$/, "");
|
||||
|
||||
return preserveCase ? withoutExtension : withoutExtension.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a PDF based on extension and MIME type
|
||||
* @param file - File or file-like object with name and type properties
|
||||
* @returns true if the file appears to be a PDF
|
||||
*/
|
||||
export function isPdfFile(
|
||||
file: { name?: string; type?: string } | File | Blob | null | undefined,
|
||||
): boolean {
|
||||
if (!file) return false;
|
||||
|
||||
const name = "name" in file ? file.name : undefined;
|
||||
const type = file.type;
|
||||
|
||||
// Check MIME type first (most reliable)
|
||||
if (type === "application/pdf") return true;
|
||||
|
||||
// Check file extension as fallback
|
||||
if (name) {
|
||||
const ext = detectFileExtension(name);
|
||||
if (ext === "pdf") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export type NonPdfFileType =
|
||||
| "image"
|
||||
| "csv"
|
||||
| "json"
|
||||
| "text"
|
||||
| "markdown"
|
||||
| "html"
|
||||
| "unknown";
|
||||
|
||||
export const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"tiff",
|
||||
"tif",
|
||||
"webp",
|
||||
]);
|
||||
const CSV_EXTENSIONS = new Set(["csv", "tsv"]);
|
||||
const JSON_EXTENSIONS = new Set(["json"]);
|
||||
const TEXT_EXTENSIONS = new Set(["txt"]);
|
||||
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
|
||||
const HTML_EXTENSIONS = new Set(["html", "htm"]);
|
||||
|
||||
/** All file extensions that the built-in viewer can render. */
|
||||
export const VIEWER_SUPPORTED_EXTENSIONS: string[] = [
|
||||
"pdf",
|
||||
...IMAGE_EXTENSIONS,
|
||||
...CSV_EXTENSIONS,
|
||||
...JSON_EXTENSIONS,
|
||||
...TEXT_EXTENSIONS,
|
||||
...MARKDOWN_EXTENSIONS,
|
||||
...HTML_EXTENSIONS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detects the non-PDF file type category for viewer routing.
|
||||
* Returns 'unknown' for PDFs or unrecognized formats.
|
||||
*/
|
||||
export function detectNonPdfFileType(
|
||||
file: { name?: string; type?: string } | File | null | undefined,
|
||||
): NonPdfFileType {
|
||||
if (!file) return "unknown";
|
||||
|
||||
const name = "name" in file ? file.name : undefined;
|
||||
const mimeType = file.type ?? "";
|
||||
|
||||
// Check MIME type first
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType === "text/csv") return "csv";
|
||||
if (mimeType === "text/tab-separated-values") return "csv";
|
||||
if (mimeType === "application/json") return "json";
|
||||
if (mimeType === "text/html") return "html";
|
||||
if (mimeType === "text/markdown") return "markdown";
|
||||
|
||||
// Fall back to extension
|
||||
if (name) {
|
||||
const ext = detectFileExtension(name);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||
if (CSV_EXTENSIONS.has(ext)) return "csv";
|
||||
if (JSON_EXTENSIONS.has(ext)) return "json";
|
||||
if (MARKDOWN_EXTENSIONS.has(ext)) return "markdown";
|
||||
if (TEXT_EXTENSIONS.has(ext)) return "text";
|
||||
if (HTML_EXTENSIONS.has(ext)) return "html";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Lightweight fuzzy search helpers without external deps
|
||||
// Provides diacritics-insensitive normalization and Levenshtein distance scoring
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}+/gu, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Basic Levenshtein distance (iterative with two rows)
|
||||
function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
if (aLen === 0) return bLen;
|
||||
if (bLen === 0) return aLen;
|
||||
|
||||
const prev = new Array(bLen + 1);
|
||||
const curr = new Array(bLen + 1);
|
||||
|
||||
for (let j = 0; j <= bLen; j++) prev[j] = j;
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
curr[0] = i;
|
||||
const aChar = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= bLen; j++) {
|
||||
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(
|
||||
prev[j] + 1, // deletion
|
||||
curr[j - 1] + 1, // insertion
|
||||
prev[j - 1] + cost, // substitution
|
||||
);
|
||||
}
|
||||
for (let j = 0; j <= bLen; j++) prev[j] = curr[j];
|
||||
}
|
||||
return curr[bLen];
|
||||
}
|
||||
|
||||
// Compute a heuristic match score (higher is better)
|
||||
// 1) Exact/substring hits get high base; 2) otherwise use normalized Levenshtein distance
|
||||
export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
||||
const query = normalizeText(queryRaw);
|
||||
const target = normalizeText(targetRaw);
|
||||
if (!query) return 0;
|
||||
if (target.includes(query)) {
|
||||
// Reward earlier/shorter substring matches
|
||||
const pos = target.indexOf(query);
|
||||
return 100 - pos - Math.max(0, target.length - query.length);
|
||||
}
|
||||
|
||||
// Token-aware: check each word token too, but require better similarity
|
||||
const tokens = target.split(/[^a-z0-9]+/g).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
if (token.includes(query)) {
|
||||
// Only give high score if the match is substantial (not just "and" matching)
|
||||
const similarity = query.length / Math.max(query.length, token.length);
|
||||
if (similarity >= 0.6) {
|
||||
// Require at least 60% similarity
|
||||
return 80 - Math.abs(token.length - query.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const distance = levenshtein(
|
||||
query,
|
||||
target.length > 64 ? target.slice(0, 64) : target,
|
||||
);
|
||||
const maxLen = Math.max(query.length, target.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
return Math.floor(similarity * 60); // scale below substring scores
|
||||
}
|
||||
|
||||
export function minScoreForQuery(query: string): number {
|
||||
const len = normalizeText(query).length;
|
||||
if (len <= 3) return 40;
|
||||
if (len <= 6) return 30;
|
||||
return 25;
|
||||
}
|
||||
|
||||
// Decide if a target matches a query based on a threshold
|
||||
export function isFuzzyMatch(
|
||||
query: string,
|
||||
target: string,
|
||||
minScore?: number,
|
||||
): boolean {
|
||||
const threshold =
|
||||
typeof minScore === "number" ? minScore : minScoreForQuery(query);
|
||||
return scoreMatch(query, target) >= threshold;
|
||||
}
|
||||
|
||||
// Convenience: rank a list of items by best score across provided getters
|
||||
export function rankByFuzzy<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getters: Array<(item: T) => string>,
|
||||
minScore?: number,
|
||||
): Array<{ item: T; score: number; matchedText?: string }> {
|
||||
const results: Array<{ item: T; score: number; matchedText?: string }> = [];
|
||||
const threshold =
|
||||
typeof minScore === "number" ? minScore : minScoreForQuery(query);
|
||||
for (const item of items) {
|
||||
let best = 0;
|
||||
let matchedText = "";
|
||||
for (const get of getters) {
|
||||
const value = get(item);
|
||||
if (!value) continue;
|
||||
const s = scoreMatch(query, value);
|
||||
if (s > best) {
|
||||
best = s;
|
||||
matchedText = value;
|
||||
}
|
||||
if (best >= 95) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (best >= threshold) results.push({ item, score: best, matchedText });
|
||||
}
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function normalizeForSearch(text: string): string {
|
||||
return normalizeText(text);
|
||||
}
|
||||
|
||||
// Convert ids like "addPassword", "add-password", "add_password" to words for matching
|
||||
export function idToWords(id: string): string {
|
||||
const spaced = id
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[._-]+/g, " ");
|
||||
return normalizeText(spaced);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* DOM utility functions for common operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clamps a value between a minimum and maximum
|
||||
* @param value - The value to clamp
|
||||
* @param min - The minimum allowed value
|
||||
* @param max - The maximum allowed value
|
||||
* @returns The clamped value
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely adds an event listener with proper cleanup
|
||||
* @param target - The target element or window/document
|
||||
* @param event - The event type
|
||||
* @param handler - The event handler function
|
||||
* @param options - Event listener options
|
||||
* @returns A cleanup function to remove the listener
|
||||
*/
|
||||
export function addEventListenerWithCleanup(
|
||||
target: EventTarget,
|
||||
event: string,
|
||||
handler: EventListener,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): () => void {
|
||||
target.addEventListener(event, handler, options);
|
||||
return () => target.removeEventListener(event, handler, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a click event occurred outside of a specified element
|
||||
* @param event - The click event
|
||||
* @param element - The element to check against
|
||||
* @returns True if the click was outside the element
|
||||
*/
|
||||
export function isClickOutside(
|
||||
event: MouseEvent,
|
||||
element: HTMLElement | null,
|
||||
): boolean {
|
||||
return element ? !element.contains(event.target as Node) : true;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
import type { WorkbenchType } from "@app/types/workbench";
|
||||
|
||||
describe("getStartupNavigationAction", () => {
|
||||
it("returns viewer + active index for 0->1 transition when not in fileEditor", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(0, 1, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(0, 1, null, "pageEditor" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns fileEditor for 0->2+ transition when not in fileEditor", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(0, 2, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "fileEditor",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not force navigation for pdfTextEditor or multiTool", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(
|
||||
0,
|
||||
1,
|
||||
"pdfTextEditor",
|
||||
"viewer" as WorkbenchType,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(
|
||||
0,
|
||||
3,
|
||||
"pdfTextEditor",
|
||||
"viewer" as WorkbenchType,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(
|
||||
0,
|
||||
1,
|
||||
"multiTool",
|
||||
"pageEditor" as WorkbenchType,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(
|
||||
0,
|
||||
3,
|
||||
"multiTool",
|
||||
"pageEditor" as WorkbenchType,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not navigate when file count decreases", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(2, 1, null, "viewer" as WorkbenchType),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(3, 1, null, "fileEditor" as WorkbenchType),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates to last file when already in viewer and files are added", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(1, 2, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 1,
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(3, 5, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not navigate when adding files in non-viewer workbenches", () => {
|
||||
expect(
|
||||
getStartupNavigationAction(1, 2, null, "fileEditor" as WorkbenchType),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(3, 4, null, "fileEditor" as WorkbenchType),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStartupNavigationAction(1, 3, null, "pageEditor" as WorkbenchType),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("handles all workbench types consistently for 0→N transitions", () => {
|
||||
// 0→1 always goes to viewer regardless of current workbench (since default is viewer)
|
||||
expect(
|
||||
getStartupNavigationAction(0, 1, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(0, 1, null, "fileEditor" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(0, 1, null, "pageEditor" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(
|
||||
0,
|
||||
1,
|
||||
null,
|
||||
"custom:formFill" as WorkbenchType,
|
||||
),
|
||||
).toEqual({
|
||||
workbench: "viewer",
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
|
||||
// 0→N (N>1) always goes to fileEditor
|
||||
expect(
|
||||
getStartupNavigationAction(0, 3, null, "viewer" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "fileEditor",
|
||||
});
|
||||
expect(
|
||||
getStartupNavigationAction(0, 3, null, "custom:myTool" as WorkbenchType),
|
||||
).toEqual({
|
||||
workbench: "fileEditor",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WorkbenchType } from "@app/types/workbench";
|
||||
|
||||
export type StartupWorkbench = "viewer" | "fileEditor";
|
||||
|
||||
export interface StartupNavigationAction {
|
||||
workbench: StartupWorkbench;
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export function getStartupNavigationAction(
|
||||
previousFileCount: number,
|
||||
currentFileCount: number,
|
||||
selectedToolKey: string | null,
|
||||
currentWorkbench: WorkbenchType,
|
||||
): StartupNavigationAction | null {
|
||||
// These tools manage their own state when files are added and should not be
|
||||
// auto-navigated away from their workbench.
|
||||
if (selectedToolKey === "pdfTextEditor" || selectedToolKey === "multiTool") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already actively viewing in the viewer → update to the latest file
|
||||
if (
|
||||
previousFileCount > 0 &&
|
||||
currentWorkbench === "viewer" &&
|
||||
currentFileCount > previousFileCount
|
||||
) {
|
||||
return { workbench: "viewer", activeFileIndex: currentFileCount - 1 };
|
||||
}
|
||||
|
||||
// From landing page (no prior files)
|
||||
if (previousFileCount === 0) {
|
||||
if (currentFileCount === 1) {
|
||||
return { workbench: "viewer", activeFileIndex: 0 };
|
||||
}
|
||||
if (currentFileCount > 1) {
|
||||
return { workbench: "fileEditor" };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
|
||||
export interface HotkeyBinding {
|
||||
code: string;
|
||||
alt?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
shift?: boolean;
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
"ShiftLeft",
|
||||
"ShiftRight",
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"MetaLeft",
|
||||
"MetaRight",
|
||||
]);
|
||||
|
||||
const CODE_LABEL_MAP: Record<string, string> = {
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Backquote: "`",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backslash: "\\",
|
||||
IntlBackslash: "\\",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Space: "Space",
|
||||
Tab: "Tab",
|
||||
Escape: "Esc",
|
||||
Enter: "Enter",
|
||||
NumpadEnter: "Num Enter",
|
||||
NumpadAdd: "Num +",
|
||||
NumpadSubtract: "Num -",
|
||||
NumpadMultiply: "Num *",
|
||||
NumpadDivide: "Num /",
|
||||
NumpadDecimal: "Num .",
|
||||
NumpadComma: "Num ,",
|
||||
NumpadEqual: "Num =",
|
||||
};
|
||||
|
||||
export const isMacLike = (): boolean => {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const platform = navigator.platform?.toLowerCase() ?? "";
|
||||
const userAgent = navigator.userAgent?.toLowerCase() ?? "";
|
||||
return (
|
||||
/mac|iphone|ipad|ipod/.test(platform) ||
|
||||
/mac|iphone|ipad|ipod/.test(userAgent)
|
||||
);
|
||||
};
|
||||
|
||||
export const isModifierCode = (code: string): boolean =>
|
||||
MODIFIER_CODES.has(code);
|
||||
|
||||
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
|
||||
|
||||
export const bindingEquals = (
|
||||
a?: HotkeyBinding | null,
|
||||
b?: HotkeyBinding | null,
|
||||
): boolean => {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
return (
|
||||
a.code === b.code &&
|
||||
Boolean(a.alt) === Boolean(b.alt) &&
|
||||
Boolean(a.ctrl) === Boolean(b.ctrl) &&
|
||||
Boolean(a.meta) === Boolean(b.meta) &&
|
||||
Boolean(a.shift) === Boolean(b.shift)
|
||||
);
|
||||
};
|
||||
|
||||
export const bindingMatchesEvent = (
|
||||
binding: HotkeyBinding,
|
||||
event: KeyboardEvent,
|
||||
): boolean => {
|
||||
return (
|
||||
event.code === binding.code &&
|
||||
event.altKey === Boolean(binding.alt) &&
|
||||
event.ctrlKey === Boolean(binding.ctrl) &&
|
||||
event.metaKey === Boolean(binding.meta) &&
|
||||
event.shiftKey === Boolean(binding.shift)
|
||||
);
|
||||
};
|
||||
|
||||
export const eventToBinding = (
|
||||
event: KeyboardEvent | ReactKeyboardEvent,
|
||||
): HotkeyBinding | null => {
|
||||
const code = event.code;
|
||||
if (!code || isModifierCode(code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binding: HotkeyBinding = {
|
||||
code,
|
||||
alt: event.altKey,
|
||||
ctrl: event.ctrlKey,
|
||||
meta: event.metaKey,
|
||||
shift: event.shiftKey,
|
||||
};
|
||||
|
||||
// Require at least one modifier to avoid clashing with text input
|
||||
if (!binding.alt && !binding.ctrl && !binding.meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return binding;
|
||||
};
|
||||
|
||||
const getKeyLabel = (code: string): string => {
|
||||
if (CODE_LABEL_MAP[code]) {
|
||||
return CODE_LABEL_MAP[code];
|
||||
}
|
||||
|
||||
if (code.startsWith("Key")) {
|
||||
return code.slice(3);
|
||||
}
|
||||
|
||||
if (code.startsWith("Digit")) {
|
||||
return code.slice(5);
|
||||
}
|
||||
|
||||
if (code.startsWith("Numpad")) {
|
||||
const remainder = code.slice(6);
|
||||
if (/^[0-9]$/.test(remainder)) {
|
||||
return `Num ${remainder}`;
|
||||
}
|
||||
return `Num ${remainder}`;
|
||||
}
|
||||
|
||||
// Match function keys (F1-F12)
|
||||
if (isFunctionKey(code)) {
|
||||
return code;
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case "ArrowUp":
|
||||
return "↑";
|
||||
case "ArrowDown":
|
||||
return "↓";
|
||||
case "ArrowLeft":
|
||||
return "←";
|
||||
case "ArrowRight":
|
||||
return "→";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDisplayParts = (
|
||||
binding: HotkeyBinding | null | undefined,
|
||||
macLike: boolean,
|
||||
): string[] => {
|
||||
if (!binding) return [];
|
||||
const parts: string[] = [];
|
||||
if (binding.meta) {
|
||||
parts.push(macLike ? "⌘" : "Win");
|
||||
}
|
||||
if (binding.ctrl) {
|
||||
parts.push(macLike ? "⌃" : "Ctrl");
|
||||
}
|
||||
if (binding.alt) {
|
||||
parts.push(macLike ? "⌥" : "Alt");
|
||||
}
|
||||
if (binding.shift) {
|
||||
parts.push(macLike ? "⇧" : "Shift");
|
||||
}
|
||||
parts.push(getKeyLabel(binding.code));
|
||||
return parts;
|
||||
};
|
||||
|
||||
export const serializeBindings = (
|
||||
bindings: Record<string, HotkeyBinding>,
|
||||
): string => {
|
||||
return JSON.stringify(bindings);
|
||||
};
|
||||
|
||||
export const deserializeBindings = (
|
||||
value: string | null | undefined,
|
||||
): Record<string, HotkeyBinding> => {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse stored hotkey bindings", error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
|
||||
code: binding.code,
|
||||
alt: Boolean(binding.alt),
|
||||
ctrl: Boolean(binding.ctrl),
|
||||
meta: Boolean(binding.meta),
|
||||
shift: Boolean(binding.shift),
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService";
|
||||
import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils";
|
||||
|
||||
export interface ImageToPdfOptions {
|
||||
imageResolution?: "full" | "reduced";
|
||||
pageFormat?: "keep" | "A4" | "letter";
|
||||
stretchToFit?: boolean;
|
||||
}
|
||||
|
||||
// Standard page sizes in PDF points (72 dpi)
|
||||
const PAGE_SIZES = {
|
||||
A4: [595.276, 841.89] as [number, number],
|
||||
Letter: [612, 792] as [number, number],
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an image file to a PDF file using PDFium WASM.
|
||||
*/
|
||||
export async function convertImageToPdf(
|
||||
imageFile: File,
|
||||
options: ImageToPdfOptions = {},
|
||||
): Promise<File> {
|
||||
const {
|
||||
imageResolution = "full",
|
||||
pageFormat = "A4",
|
||||
stretchToFit = false,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
// Read the image file
|
||||
let imageBlob: Blob = imageFile;
|
||||
|
||||
// Apply image resolution reduction if requested
|
||||
if (imageResolution === "reduced") {
|
||||
imageBlob = await reduceImageResolution(imageFile, 1200);
|
||||
}
|
||||
|
||||
// Decode image to RGBA pixels via canvas
|
||||
const decoded = await decodeImageToRgba(imageBlob);
|
||||
if (!decoded) {
|
||||
throw new Error("Failed to decode image");
|
||||
}
|
||||
|
||||
const { rgba, width: imageWidth, height: imageHeight } = decoded;
|
||||
|
||||
// Determine page dimensions
|
||||
let pageWidth: number;
|
||||
let pageHeight: number;
|
||||
|
||||
if (pageFormat === "keep") {
|
||||
pageWidth = imageWidth;
|
||||
pageHeight = imageHeight;
|
||||
} else if (pageFormat === "letter") {
|
||||
[pageWidth, pageHeight] = PAGE_SIZES.Letter;
|
||||
} else {
|
||||
[pageWidth, pageHeight] = PAGE_SIZES.A4;
|
||||
}
|
||||
|
||||
// Adjust orientation to match image
|
||||
if (pageFormat !== "keep") {
|
||||
const imageIsLandscape = imageWidth > imageHeight;
|
||||
const pageIsLandscape = pageWidth > pageHeight;
|
||||
if (imageIsLandscape !== pageIsLandscape) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate image placement
|
||||
let drawX: number;
|
||||
let drawY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
|
||||
if (stretchToFit || pageFormat === "keep") {
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageHeight;
|
||||
} else {
|
||||
const imageAspectRatio = imageWidth / imageHeight;
|
||||
const pageAspectRatio = pageWidth / pageHeight;
|
||||
|
||||
if (imageAspectRatio > pageAspectRatio) {
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageWidth / imageAspectRatio;
|
||||
drawX = 0;
|
||||
drawY = (pageHeight - drawHeight) / 2;
|
||||
} else {
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = pageHeight * imageAspectRatio;
|
||||
drawY = 0;
|
||||
drawX = (pageWidth - drawWidth) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new PDF document
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error("PDFium: failed to create document");
|
||||
|
||||
try {
|
||||
// Create a page
|
||||
const pagePtr = m.FPDFPage_New(docPtr, 0, pageWidth, pageHeight);
|
||||
if (!pagePtr) throw new Error("PDFium: failed to create page");
|
||||
|
||||
// Create bitmap from RGBA data (PDFium uses BGRA)
|
||||
const bitmapPtr = m.FPDFBitmap_Create(imageWidth, imageHeight, 1);
|
||||
if (!bitmapPtr) throw new Error("PDFium: failed to create bitmap");
|
||||
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
|
||||
// Bulk RGBA → BGRA copy via shared utility
|
||||
copyRgbaToBgraHeap(m, rgba, bufferPtr, imageWidth, imageHeight, stride);
|
||||
|
||||
// Create image page object
|
||||
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
|
||||
if (!imageObjPtr) {
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
throw new Error("PDFium: failed to create image object");
|
||||
}
|
||||
|
||||
const setBitmapOk = m.FPDFImageObj_SetBitmap(
|
||||
pagePtr,
|
||||
0,
|
||||
imageObjPtr,
|
||||
bitmapPtr,
|
||||
);
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
if (!setBitmapOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
throw new Error("PDFium: failed to set bitmap on image object");
|
||||
}
|
||||
|
||||
// Set transformation matrix: scale + translate
|
||||
// FS_MATRIX: {a, b, c, d, e, f} — 6 floats
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a = scaleX
|
||||
m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b
|
||||
m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c
|
||||
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d = scaleY
|
||||
m.pdfium.setValue(matrixPtr + 16, drawX, "float"); // e = translateX
|
||||
m.pdfium.setValue(matrixPtr + 20, drawY, "float"); // f = translateY
|
||||
|
||||
const setMatrixOk = m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr);
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
|
||||
if (!setMatrixOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
throw new Error("PDFium: failed to set image matrix");
|
||||
}
|
||||
|
||||
// Insert image into page
|
||||
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
|
||||
|
||||
// Generate page content stream
|
||||
m.FPDFPage_GenerateContent(pagePtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
|
||||
// Save document
|
||||
const pdfBytes = await saveRawDocument(docPtr);
|
||||
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, ".pdf");
|
||||
|
||||
return new File([pdfBytes], pdfFilename, { type: "application/pdf" });
|
||||
} finally {
|
||||
m.FPDF_CloseDocument(docPtr);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error converting image to PDF:", error);
|
||||
throw new Error(
|
||||
`Failed to convert image to PDF: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an image Blob to RGBA pixel data via canvas.
|
||||
*/
|
||||
function decodeImageToRgba(
|
||||
imageBlob: Blob,
|
||||
): Promise<{ rgba: Uint8Array; width: number; height: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageBlob);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({
|
||||
rgba: new Uint8Array(imageData.data.buffer),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce image resolution to a maximum dimension
|
||||
*/
|
||||
async function reduceImageResolution(
|
||||
imageFile: File,
|
||||
maxDimension: number,
|
||||
): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const { width, height } = img;
|
||||
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(imageFile);
|
||||
return;
|
||||
}
|
||||
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
if (width > height) {
|
||||
newWidth = maxDimension;
|
||||
newHeight = (height / width) * maxDimension;
|
||||
} else {
|
||||
newHeight = maxDimension;
|
||||
newWidth = (width / height) * maxDimension;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get canvas context");
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
const outputType = imageFile.type.startsWith("image/")
|
||||
? imageFile.type
|
||||
: "image/jpeg";
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to convert canvas to blob"));
|
||||
return;
|
||||
}
|
||||
const reducedFile = new File([blob], imageFile.name, {
|
||||
type: outputType,
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(reducedFile);
|
||||
},
|
||||
outputType,
|
||||
0.9,
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image
|
||||
*/
|
||||
export function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
export interface TransparencyOptions {
|
||||
lowerBound?: { r: number; g: number; b: number };
|
||||
upperBound?: { r: number; g: number; b: number };
|
||||
autoDetectCorner?: boolean;
|
||||
tolerance?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOWER_BOUND = { r: 200, g: 200, b: 200 };
|
||||
const DEFAULT_UPPER_BOUND = { r: 255, g: 255, b: 255 }; // #FFFFFF
|
||||
|
||||
export async function removeWhiteBackground(
|
||||
imageFile: File | string,
|
||||
options: TransparencyOptions = {},
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const result = processImageTransparency(img, options);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
|
||||
if (typeof imageFile === "string") {
|
||||
img.src = imageFile;
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Failed to read image file"));
|
||||
};
|
||||
reader.readAsDataURL(imageFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function processImageTransparency(
|
||||
img: HTMLImageElement,
|
||||
options: TransparencyOptions,
|
||||
): string {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Failed to get canvas context");
|
||||
}
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
let lowerBound = options.lowerBound || DEFAULT_LOWER_BOUND;
|
||||
let upperBound = options.upperBound || DEFAULT_UPPER_BOUND;
|
||||
|
||||
if (options.autoDetectCorner) {
|
||||
const cornerColor = detectCornerColor(imageData);
|
||||
const tolerance = options.tolerance || 10;
|
||||
lowerBound = {
|
||||
r: Math.max(0, cornerColor.r - tolerance),
|
||||
g: Math.max(0, cornerColor.g - tolerance),
|
||||
b: Math.max(0, cornerColor.b - tolerance),
|
||||
};
|
||||
upperBound = {
|
||||
r: Math.min(255, cornerColor.r + tolerance),
|
||||
g: Math.min(255, cornerColor.g + tolerance),
|
||||
b: Math.min(255, cornerColor.b + tolerance),
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (
|
||||
r >= lowerBound.r &&
|
||||
r <= upperBound.r &&
|
||||
g >= lowerBound.g &&
|
||||
g <= upperBound.g &&
|
||||
b >= lowerBound.b &&
|
||||
b <= upperBound.b
|
||||
) {
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function detectCornerColor(imageData: ImageData): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
} {
|
||||
const { width, height, data } = imageData;
|
||||
|
||||
const sampleSize = 5;
|
||||
const corners = [
|
||||
{ x: 0, y: 0 }, // top-left
|
||||
{ x: width - sampleSize, y: 0 }, // top-right
|
||||
{ x: 0, y: height - sampleSize }, // bottom-left
|
||||
{ x: width - sampleSize, y: height - sampleSize }, // bottom-right
|
||||
];
|
||||
|
||||
let totalR = 0,
|
||||
totalG = 0,
|
||||
totalB = 0;
|
||||
let samples = 0;
|
||||
|
||||
corners.forEach((corner) => {
|
||||
for (let dy = 0; dy < sampleSize; dy++) {
|
||||
for (let dx = 0; dx < sampleSize; dx++) {
|
||||
const x = Math.min(width - 1, Math.max(0, corner.x + dx));
|
||||
const y = Math.min(height - 1, Math.max(0, corner.y + dy));
|
||||
const i = (y * width + x) * 4;
|
||||
|
||||
totalR += data[i];
|
||||
totalG += data[i + 1];
|
||||
totalB += data[i + 2];
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
r: Math.round(totalR / samples),
|
||||
g: Math.round(totalG / samples),
|
||||
b: Math.round(totalB / samples),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
cv?: any;
|
||||
jscanify?: any;
|
||||
}
|
||||
}
|
||||
|
||||
const OPENCV_SRC = "/vendor/jscanify/opencv.js";
|
||||
const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js";
|
||||
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
function injectScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// data-loaded: load event fired. data-loading: in flight.
|
||||
// Neither: foreign tag (e.g. HMR-preserved); trust the downstream
|
||||
// global check rather than wait on a load event that may already
|
||||
// have dispatched.
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${src}"]`,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === "true") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (existing.dataset.loading === "true") {
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error(`Failed to load ${src}`)),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.dataset.loading = "true";
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = "true";
|
||||
delete script.dataset.loading;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
delete script.dataset.loading;
|
||||
reject(new Error(`Failed to load ${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForGlobal(
|
||||
check: () => boolean,
|
||||
name: string,
|
||||
timeoutMs: number,
|
||||
onProgress?: (elapsedMs: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const tick = () => {
|
||||
if (check()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed > timeoutMs) {
|
||||
reject(new Error(`Timed out waiting for ${name}`));
|
||||
return;
|
||||
}
|
||||
onProgress?.(elapsed);
|
||||
setTimeout(tick, 100);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
export interface LoadJscanifyOptions {
|
||||
onStatus?: (status: string) => void;
|
||||
}
|
||||
|
||||
export function loadJscanify(options: LoadJscanifyOptions = {}): Promise<void> {
|
||||
const { onStatus } = options;
|
||||
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = (async () => {
|
||||
onStatus?.("Loading OpenCV...");
|
||||
await injectScript(OPENCV_SRC);
|
||||
// OpenCV's script load event fires before the WASM runtime is ready.
|
||||
await waitForGlobal(
|
||||
() => !!window.cv && !!window.cv.Mat,
|
||||
"OpenCV runtime (cv.Mat)",
|
||||
15000,
|
||||
(elapsed) => {
|
||||
if (elapsed > 2000) onStatus?.("Initializing OpenCV runtime...");
|
||||
},
|
||||
);
|
||||
|
||||
onStatus?.("Loading jscanify...");
|
||||
await injectScript(JSCANIFY_SRC);
|
||||
await waitForGlobal(() => !!window.jscanify, "jscanify global", 5000);
|
||||
|
||||
onStatus?.("Scanner ready");
|
||||
})().catch((err) => {
|
||||
loadPromise = null;
|
||||
throw err;
|
||||
});
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ProcessedFileMetadata,
|
||||
ProcessedFilePage,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
|
||||
export interface PageDimensions {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export function getPageDimensions(
|
||||
page?: ProcessedFilePage | null,
|
||||
): PageDimensions {
|
||||
const width =
|
||||
typeof page?.width === "number" && page.width > 0 ? page.width : null;
|
||||
const height =
|
||||
typeof page?.height === "number" && page.height > 0 ? page.height : null;
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getFirstPageDimensionsFromMetadata(
|
||||
metadata?: ProcessedFileMetadata | null,
|
||||
): PageDimensions {
|
||||
if (!metadata?.pages?.length) {
|
||||
return { width: null, height: null };
|
||||
}
|
||||
|
||||
return getPageDimensions(metadata.pages[0]);
|
||||
}
|
||||
|
||||
export function getFirstPageDimensionsFromStub(
|
||||
file?: StirlingFileStub,
|
||||
): PageDimensions {
|
||||
return getFirstPageDimensionsFromMetadata(file?.processedFile);
|
||||
}
|
||||
|
||||
export function getFirstPageAspectRatioFromMetadata(
|
||||
metadata?: ProcessedFileMetadata | null,
|
||||
): number | null {
|
||||
const { width, height } = getFirstPageDimensionsFromMetadata(metadata);
|
||||
if (width && height) {
|
||||
return height / width;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFirstPageAspectRatioFromStub(
|
||||
file?: StirlingFileStub,
|
||||
): number | null {
|
||||
return getFirstPageAspectRatioFromMetadata(file?.processedFile);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const validatePageNumbers = (pageNumbers: string): boolean => {
|
||||
if (!pageNumbers.trim()) return false;
|
||||
|
||||
// Normalize input for validation: remove spaces around commas and other spaces
|
||||
const normalized = pageNumbers.replace(/\s*,\s*/g, ",").replace(/\s+/g, "");
|
||||
const parts = normalized.split(",");
|
||||
|
||||
// Regular expressions for different page number formats
|
||||
const allToken = /^all$/i; // Select all pages
|
||||
const singlePageRegex = /^[1-9]\d*$/; // Single page: positive integers only (no 0)
|
||||
const rangeRegex = /^[1-9]\d*-(?:[1-9]\d*)?$/; // Range: 1-5 or open range 10-
|
||||
const mathRegex = /^(?=.*n)[0-9n+\-*/() ]+$/; // Mathematical expressions with n and allowed chars
|
||||
|
||||
return parts.every((part) => {
|
||||
if (!part) return false;
|
||||
return (
|
||||
allToken.test(part) ||
|
||||
singlePageRegex.test(part) ||
|
||||
rangeRegex.test(part) ||
|
||||
mathRegex.test(part)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
// Browser page translators (Edge, Chrome, extensions) wrap text nodes in
|
||||
// injected <font> elements, reparenting nodes React is holding. React's commit
|
||||
// phase then throws NotFoundError on removeChild/insertBefore and the
|
||||
// ErrorBoundary unmounts the app. https://github.com/facebook/react/issues/11538
|
||||
//
|
||||
// We watch for translator fingerprints (Google Translate's translated-* class
|
||||
// on <html>, or any injected <font>) and install guards on Node.prototype only
|
||||
// once one appears, so native DOM semantics are preserved when no translator
|
||||
// is active.
|
||||
|
||||
declare global {
|
||||
interface Node {
|
||||
__stirlingTranslatorPatched?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
let patchApplied = false;
|
||||
|
||||
function isGoogleTranslateActive(): boolean {
|
||||
const cls = document.documentElement.classList;
|
||||
return cls.contains("translated-ltr") || cls.contains("translated-rtl");
|
||||
}
|
||||
|
||||
function applyDomPatch(trigger: string): void {
|
||||
if (patchApplied) return;
|
||||
if (typeof Node === "undefined" || !Node.prototype) return;
|
||||
if (Node.prototype.__stirlingTranslatorPatched) return;
|
||||
patchApplied = true;
|
||||
Node.prototype.__stirlingTranslatorPatched = true;
|
||||
|
||||
console.warn(
|
||||
`[dom-patch] Browser page translator detected (${trigger}). ` +
|
||||
"Installing removeChild/insertBefore guards to prevent React crashes. " +
|
||||
"The UI may show minor glitches while the translator is active.",
|
||||
);
|
||||
|
||||
const originalRemoveChild = Node.prototype.removeChild;
|
||||
Node.prototype.removeChild = function patchedRemoveChild<T extends Node>(
|
||||
this: Node,
|
||||
child: T,
|
||||
): T {
|
||||
if (child.parentNode !== this) return child;
|
||||
return originalRemoveChild.call(this, child) as T;
|
||||
} as typeof Node.prototype.removeChild;
|
||||
|
||||
const originalInsertBefore = Node.prototype.insertBefore;
|
||||
Node.prototype.insertBefore = function patchedInsertBefore<T extends Node>(
|
||||
this: Node,
|
||||
newNode: T,
|
||||
referenceNode: Node | null,
|
||||
): T {
|
||||
if (referenceNode && referenceNode.parentNode !== this) return newNode;
|
||||
return originalInsertBefore.call(this, newNode, referenceNode) as T;
|
||||
} as typeof Node.prototype.insertBefore;
|
||||
}
|
||||
|
||||
export function armTranslatorDetector(): void {
|
||||
if (typeof window === "undefined" || typeof MutationObserver === "undefined")
|
||||
return;
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
|
||||
// Edge case: class already set (e.g., bfcache restore).
|
||||
if (isGoogleTranslateActive()) {
|
||||
applyDomPatch("html class was already translated-* on arm");
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (
|
||||
m.type === "attributes" &&
|
||||
m.target === document.documentElement &&
|
||||
isGoogleTranslateActive()
|
||||
) {
|
||||
applyDomPatch("<html> translated-* class appeared");
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if (m.type === "childList") {
|
||||
for (const n of m.addedNodes) {
|
||||
if (n.nodeName === "FONT") {
|
||||
applyDomPatch("<font> element injected into DOM");
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
armTranslatorDetector();
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
|
||||
*
|
||||
* Migrated from @cantoo/pdf-lib to @embedpdf/pdfium WASM.
|
||||
* All operations are performed via PDFium C API wrappers.
|
||||
*/
|
||||
import {
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeDocAndFreeBuffer,
|
||||
saveRawDocument,
|
||||
readUtf16,
|
||||
writeUtf16,
|
||||
readAnnotRectAdjusted,
|
||||
parseRectToCss,
|
||||
} from "@app/services/pdfiumService";
|
||||
import {
|
||||
FPDF_ANNOT_LINK,
|
||||
PDFACTION_GOTO,
|
||||
PDFACTION_URI,
|
||||
} from "@app/utils/pdfiumBitmapUtils";
|
||||
|
||||
export type LinkType = "internal" | "external" | "unknown";
|
||||
export type LinkBorderStyle =
|
||||
| "solid"
|
||||
| "dashed"
|
||||
| "beveled"
|
||||
| "inset"
|
||||
| "underline";
|
||||
export type LinkHighlightMode = "none" | "invert" | "outline" | "push";
|
||||
|
||||
export interface PdfLibLink {
|
||||
id: string;
|
||||
/** Index of this annotation in the page's /Annots array (used for deletion matching). */
|
||||
annotIndex: number;
|
||||
/** Rectangle in PDF-page coordinate space (top-left origin, unscaled). */
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
type: LinkType;
|
||||
/** 0-based target page index (internal links). */
|
||||
targetPage?: number;
|
||||
/** URI for external links. */
|
||||
uri?: string;
|
||||
/** Tooltip / alt text from the /Contents entry. */
|
||||
title?: string;
|
||||
/** RGB color of the link annotation border (each component 0–1). */
|
||||
color?: [number, number, number];
|
||||
/** Border width and style. */
|
||||
borderStyle?: { width: number; style: LinkBorderStyle };
|
||||
/** Visual feedback when the link is clicked. */
|
||||
highlightMode?: LinkHighlightMode;
|
||||
}
|
||||
|
||||
export interface CreateLinkOptions {
|
||||
/** 0-based page index. */
|
||||
pageIndex: number;
|
||||
/** Link rectangle in CSS (top-left origin) coordinate space. */
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
/** External URL (mutually exclusive with destinationPage). */
|
||||
url?: string;
|
||||
/** Internal destination page index, 0-based (mutually exclusive with url). */
|
||||
destinationPage?: number;
|
||||
/** Tooltip text shown on hover (stored in /Contents). */
|
||||
title?: string;
|
||||
/** RGB colour for the border, each component 0–1. Defaults to blue. */
|
||||
color?: [number, number, number];
|
||||
/** Border width in points. 0 = invisible (PDF convention). */
|
||||
borderWidth?: number;
|
||||
/** Border line style. */
|
||||
borderStyle?: LinkBorderStyle;
|
||||
/** Visual feedback when the link is clicked. */
|
||||
highlightMode?: LinkHighlightMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a link annotation on a PDF page.
|
||||
* Mutates the document in-place and returns the updated PDF bytes.
|
||||
*/
|
||||
export async function createLinkAnnotation(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
options: CreateLinkOptions,
|
||||
password?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const {
|
||||
pageIndex,
|
||||
rect,
|
||||
url,
|
||||
destinationPage,
|
||||
title,
|
||||
color = [0, 0, 1],
|
||||
borderWidth = 0,
|
||||
} = options;
|
||||
|
||||
if (!url && destinationPage === undefined) {
|
||||
throw new Error(
|
||||
"createLinkAnnotation: must provide either url or destinationPage",
|
||||
);
|
||||
}
|
||||
if (url && destinationPage !== undefined) {
|
||||
throw new Error(
|
||||
"createLinkAnnotation: url and destinationPage are mutually exclusive",
|
||||
);
|
||||
}
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
throw new Error("createLinkAnnotation: rect dimensions must be positive");
|
||||
}
|
||||
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
try {
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
if (
|
||||
destinationPage !== undefined &&
|
||||
(destinationPage < 0 || destinationPage >= pageCount)
|
||||
) {
|
||||
throw new RangeError(
|
||||
`createLinkAnnotation: destinationPage ${destinationPage} out of range [0, ${pageCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
|
||||
|
||||
try {
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
|
||||
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_LINK);
|
||||
if (!annotPtr) {
|
||||
throw new Error("Failed to create link annotation");
|
||||
}
|
||||
|
||||
try {
|
||||
// Set rect (convert from CSS top-left to PDF bottom-left origin)
|
||||
// FS_RECTF layout: { left, top, right, bottom } where top > bottom in PDF coords
|
||||
const pdfLeft = rect.x;
|
||||
const pdfTop = pageHeight - rect.y; // CSS y=0 → PDF top
|
||||
const pdfRight = rect.x + rect.width;
|
||||
const pdfBottom = pageHeight - rect.y - rect.height; // CSS bottom → PDF bottom
|
||||
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
m.pdfium.setValue(rectBuf, pdfLeft, "float"); // offset 0: left
|
||||
m.pdfium.setValue(rectBuf + 4, pdfTop, "float"); // offset 4: top (larger y)
|
||||
m.pdfium.setValue(rectBuf + 8, pdfRight, "float"); // offset 8: right
|
||||
m.pdfium.setValue(rectBuf + 12, pdfBottom, "float"); // offset 12: bottom (smaller y)
|
||||
m.FPDFAnnot_SetRect(annotPtr, rectBuf);
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
// Set color
|
||||
// FPDFANNOT_COLORTYPE_Color = 0
|
||||
m.FPDFAnnot_SetColor(
|
||||
annotPtr,
|
||||
0,
|
||||
Math.round(color[0] * 255),
|
||||
Math.round(color[1] * 255),
|
||||
Math.round(color[2] * 255),
|
||||
255,
|
||||
);
|
||||
|
||||
// Set border
|
||||
m.FPDFAnnot_SetBorder(annotPtr, 0, 0, borderWidth);
|
||||
|
||||
// Set URI for external links
|
||||
if (url) {
|
||||
const uriPtr = writeUtf16(m, url);
|
||||
m.FPDFAnnot_SetURI(annotPtr, uriPtr);
|
||||
m.pdfium.wasmExports.free(uriPtr);
|
||||
}
|
||||
|
||||
// Set title / contents
|
||||
if (title) {
|
||||
const titlePtr = writeUtf16(m, title);
|
||||
m.FPDFAnnot_SetStringValue(annotPtr, "Contents", titlePtr);
|
||||
m.pdfium.wasmExports.free(titlePtr);
|
||||
}
|
||||
} finally {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
} finally {
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
return await saveRawDocument(docPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link annotation from a page by its index.
|
||||
* Returns the updated PDF bytes.
|
||||
*/
|
||||
export async function removeLinkAnnotation(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
pageIndex: number,
|
||||
annotIndex: number,
|
||||
password?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
try {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
|
||||
|
||||
m.FPDFPage_RemoveAnnot(pagePtr, annotIndex);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
|
||||
return await saveRawDocument(docPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all link annotations from a given PDF page.
|
||||
*/
|
||||
export async function extractLinksFromPage(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
pageIndex: number,
|
||||
password?: string,
|
||||
): Promise<{
|
||||
links: PdfLibLink[];
|
||||
pdfPageWidth: number;
|
||||
pdfPageHeight: number;
|
||||
}> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
try {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) return { links: [], pdfPageWidth: 0, pdfPageHeight: 0 };
|
||||
|
||||
const pageWidth = m.FPDF_GetPageWidthF(pagePtr);
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
const links: PdfLibLink[] = [];
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
|
||||
for (let i = 0; i < annotCount; i++) {
|
||||
try {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i);
|
||||
if (!annotPtr) continue;
|
||||
|
||||
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||
if (subtype !== FPDF_ANNOT_LINK) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get rect (CropBox-adjusted for correct overlay positioning)
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
const hasRect = readAnnotRectAdjusted(m, annotPtr, rectBuf);
|
||||
if (!hasRect) {
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = parseRectToCss(m, rectBuf, pageHeight);
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
// Try to get link object
|
||||
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
|
||||
let linkType: LinkType = "unknown";
|
||||
let targetPage: number | undefined;
|
||||
let uri: string | undefined;
|
||||
|
||||
if (linkPtr) {
|
||||
// Check for action
|
||||
const actionPtr = m.FPDFLink_GetAction(linkPtr);
|
||||
if (actionPtr) {
|
||||
const actionType = m.FPDFAction_GetType(actionPtr);
|
||||
if (actionType === PDFACTION_URI) {
|
||||
const uriLen = m.FPDFAction_GetURIPath(docPtr, actionPtr, 0, 0);
|
||||
if (uriLen > 0) {
|
||||
const uriBuf = m.pdfium.wasmExports.malloc(uriLen);
|
||||
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
|
||||
uri = m.pdfium.UTF8ToString(uriBuf);
|
||||
m.pdfium.wasmExports.free(uriBuf);
|
||||
linkType = "external";
|
||||
}
|
||||
} else if (actionType === PDFACTION_GOTO) {
|
||||
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = "internal";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for direct destination
|
||||
if (linkType === "unknown") {
|
||||
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = "internal";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get title from /Contents
|
||||
let title: string | undefined;
|
||||
const contentsLen = m.FPDFAnnot_GetStringValue(
|
||||
annotPtr,
|
||||
"Contents",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
if (contentsLen > 2) {
|
||||
const contentsBuf = m.pdfium.wasmExports.malloc(contentsLen);
|
||||
m.FPDFAnnot_GetStringValue(
|
||||
annotPtr,
|
||||
"Contents",
|
||||
contentsBuf,
|
||||
contentsLen,
|
||||
);
|
||||
title = readUtf16(m, contentsBuf, contentsLen) || undefined;
|
||||
m.pdfium.wasmExports.free(contentsBuf);
|
||||
}
|
||||
|
||||
// Get color
|
||||
let color: [number, number, number] | undefined;
|
||||
// allocate 4 uint for RGBA
|
||||
const rPtr = m.pdfium.wasmExports.malloc(16);
|
||||
const gPtr = rPtr + 4;
|
||||
const bPtr = rPtr + 8;
|
||||
const aPtr = rPtr + 12;
|
||||
const hasColor = m.FPDFAnnot_GetColor(
|
||||
annotPtr,
|
||||
0,
|
||||
rPtr,
|
||||
gPtr,
|
||||
bPtr,
|
||||
aPtr,
|
||||
);
|
||||
if (hasColor) {
|
||||
color = [
|
||||
m.pdfium.getValue(rPtr, "i32") / 255,
|
||||
m.pdfium.getValue(gPtr, "i32") / 255,
|
||||
m.pdfium.getValue(bPtr, "i32") / 255,
|
||||
];
|
||||
}
|
||||
m.pdfium.wasmExports.free(rPtr);
|
||||
|
||||
links.push({
|
||||
id: `link-${pageIndex}-${i}`,
|
||||
annotIndex: i,
|
||||
rect,
|
||||
type: linkType,
|
||||
targetPage,
|
||||
uri,
|
||||
title,
|
||||
color,
|
||||
});
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
} catch (e) {
|
||||
console.warn("[pdfLinkUtils] Failed to parse annotation:", e);
|
||||
}
|
||||
}
|
||||
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
return { links, pdfPageWidth: pageWidth, pdfPageHeight: pageHeight };
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* pdfiumBitmapUtils — Shared utilities for working with PDFium WASM bitmaps.
|
||||
*
|
||||
* Centralises the RGBA→BGRA pixel conversion and image-to-page-object
|
||||
* embedding that previously appeared (copy-pasted) in at least 5 files.
|
||||
*
|
||||
* Performance note: instead of calling `m.pdfium.setValue()` per pixel
|
||||
* (which crosses the JS↔WASM FFI boundary on every call), we perform
|
||||
* the colour-channel swizzle in a plain JS TypedArray and then bulk-
|
||||
* copy the result into the WASM heap with a single `HEAPU8.set()`.
|
||||
*/
|
||||
import type { WrappedPdfiumModule } from "@embedpdf/pdfium";
|
||||
|
||||
/** FPDF_ANNOT_LINK */
|
||||
export const FPDF_ANNOT_LINK = 4;
|
||||
/** FPDF_ANNOT_WIDGET (form field) */
|
||||
export const FPDF_ANNOT_WIDGET = 20;
|
||||
/** FPDF_ANNOT_INK */
|
||||
export const FPDF_ANNOT_INK = 15;
|
||||
/** FPDF_ANNOT_LINE */
|
||||
export const FPDF_ANNOT_LINE = 3;
|
||||
/** PDFACTION_GOTO */
|
||||
export const PDFACTION_GOTO = 1;
|
||||
/** PDFACTION_URI */
|
||||
export const PDFACTION_URI = 3;
|
||||
/** FLAT_PRINT (for FPDFPage_Flatten) */
|
||||
export const FLAT_PRINT = 2;
|
||||
|
||||
/**
|
||||
* Convert an RGBA pixel buffer to BGRA (PDFium's expected format) **in place
|
||||
* inside the WASM heap** with a single bulk memcpy.
|
||||
*
|
||||
* When `stride === width * 4` the copy is a single `HEAPU8.set()`.
|
||||
* When the bitmap has padding (stride > width * 4), rows are copied
|
||||
* individually to skip the padding bytes.
|
||||
*
|
||||
* This is ~100× faster than per-pixel `m.pdfium.setValue()` calls for
|
||||
* large images.
|
||||
*/
|
||||
export function copyRgbaToBgraHeap(
|
||||
m: WrappedPdfiumModule,
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
bufferPtr: number,
|
||||
width: number,
|
||||
height: number,
|
||||
stride: number,
|
||||
): void {
|
||||
const rowBytes = width * 4;
|
||||
|
||||
if (stride === rowBytes) {
|
||||
// Fast path: no padding — single bulk copy after swizzle
|
||||
const bgra = new Uint8Array(rgba.length);
|
||||
for (let i = 0; i < rgba.length; i += 4) {
|
||||
bgra[i] = rgba[i + 2]; // B
|
||||
bgra[i + 1] = rgba[i + 1]; // G
|
||||
bgra[i + 2] = rgba[i]; // R
|
||||
bgra[i + 3] = rgba[i + 3]; // A
|
||||
}
|
||||
new Uint8Array((m.pdfium.wasmExports as any).memory.buffer).set(
|
||||
bgra,
|
||||
bufferPtr,
|
||||
);
|
||||
} else {
|
||||
// Stride has padding — swizzle + copy row by row
|
||||
const rowBuf = new Uint8Array(rowBytes);
|
||||
const heap = new Uint8Array((m.pdfium.wasmExports as any).memory.buffer);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const srcRowStart = y * rowBytes;
|
||||
for (let x = 0; x < rowBytes; x += 4) {
|
||||
rowBuf[x] = rgba[srcRowStart + x + 2]; // B
|
||||
rowBuf[x + 1] = rgba[srcRowStart + x + 1]; // G
|
||||
rowBuf[x + 2] = rgba[srcRowStart + x]; // R
|
||||
rowBuf[x + 3] = rgba[srcRowStart + x + 3]; // A
|
||||
}
|
||||
heap.set(rowBuf, bufferPtr + y * stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
export interface DecodedImage {
|
||||
rgba: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image
|
||||
* page object, position it via an affine matrix, and insert it into the page.
|
||||
*
|
||||
* Returns `true` if the image was successfully inserted, `false` otherwise.
|
||||
* All intermediate WASM resources are cleaned up on failure.
|
||||
*/
|
||||
export function embedBitmapImageOnPage(
|
||||
m: WrappedPdfiumModule,
|
||||
docPtr: number,
|
||||
pagePtr: number,
|
||||
image: DecodedImage,
|
||||
pdfX: number,
|
||||
pdfY: number,
|
||||
drawWidth: number,
|
||||
drawHeight: number,
|
||||
): boolean {
|
||||
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
|
||||
if (!bitmapPtr) return false;
|
||||
|
||||
try {
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
|
||||
copyRgbaToBgraHeap(
|
||||
m,
|
||||
image.rgba,
|
||||
bufferPtr,
|
||||
image.width,
|
||||
image.height,
|
||||
stride,
|
||||
);
|
||||
|
||||
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
|
||||
if (!imageObjPtr) return false;
|
||||
|
||||
const setBitmapOk = m.FPDFImageObj_SetBitmap(
|
||||
pagePtr,
|
||||
0,
|
||||
imageObjPtr,
|
||||
bitmapPtr,
|
||||
);
|
||||
if (!setBitmapOk) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- early-destroy the bitmap; PDFium has copied the pixel data internally
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
// Set affine transform: [a b c d e f]
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
try {
|
||||
m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a — scaleX
|
||||
m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b
|
||||
m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c
|
||||
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d — scaleY
|
||||
m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); // e — translateX
|
||||
m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); // f — translateY
|
||||
|
||||
if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
|
||||
m.FPDFPageObj_Destroy(imageObjPtr);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
}
|
||||
|
||||
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
|
||||
return true;
|
||||
} finally {
|
||||
// Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium
|
||||
// builds but guard anyway. If already destroyed above, the second call
|
||||
// is harmless because we allow it to be idempotent.
|
||||
// We use a try-catch to be safe across PDFium WASM builds.
|
||||
try {
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
} catch {
|
||||
/* already freed */
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Draw a simple light-grey rectangle as a placeholder for annotations
|
||||
* that could not be rendered.
|
||||
*/
|
||||
export function drawPlaceholderRect(
|
||||
m: WrappedPdfiumModule,
|
||||
pagePtr: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
|
||||
if (!pathPtr) return;
|
||||
|
||||
m.FPDFPath_LineTo(pathPtr, x + width, y);
|
||||
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
|
||||
m.FPDFPath_LineTo(pathPtr, x, y + height);
|
||||
m.FPDFPath_Close(pathPtr);
|
||||
|
||||
m.FPDFPageObj_SetFillColor(pathPtr, 230, 230, 230, 150);
|
||||
m.FPDFPageObj_SetStrokeColor(pathPtr, 128, 128, 128, 255);
|
||||
m.FPDFPageObj_SetStrokeWidth(pathPtr, 1);
|
||||
|
||||
// fillMode 1 = alternate fill, stroke = true
|
||||
m.FPDFPath_SetDrawMode(pathPtr, 1, true);
|
||||
|
||||
m.FPDFPage_InsertObject(pagePtr, pathPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an image data URL (e.g. `data:image/png;base64,...`) to raw RGBA
|
||||
* pixel data via an offscreen canvas.
|
||||
*/
|
||||
export function decodeImageDataUrl(
|
||||
dataUrl: string,
|
||||
): Promise<DecodedImage | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
resolve({
|
||||
rgba: new Uint8Array(imageData.data.buffer),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* pdfiumPageRender — render a single PDF page from an already-opened PDFium
|
||||
* document pointer to a canvas data URL.
|
||||
*
|
||||
* Shared by the first-page thumbnail path (thumbnailUtils.ts) and the
|
||||
* per-page thumbnail service (thumbnailGenerationService.ts) so the pixel-
|
||||
* copy + white-background logic lives in one place.
|
||||
*/
|
||||
import { getPdfiumModule } from "@app/services/pdfiumService";
|
||||
|
||||
/** FPDF_ANNOT (0x01) | FPDF_LCD_TEXT (0x10). */
|
||||
const PDFIUM_RENDER_FLAGS = 0x01 | 0x10;
|
||||
|
||||
export interface RenderPdfiumPageOptions {
|
||||
/** When true (default), bake the page's own rotation into the bitmap.
|
||||
* When false, render upright so callers can apply CSS rotation. */
|
||||
applyRotation?: boolean;
|
||||
/** Output format; defaults to PNG. */
|
||||
format?: "png" | "jpeg";
|
||||
/** JPEG quality [0,1]; ignored for PNG. */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single page (0-indexed) of an open PDFium document into a data URL.
|
||||
*
|
||||
* The caller is responsible for opening and closing the document pointer.
|
||||
*/
|
||||
export async function renderPdfiumPageDataUrl(
|
||||
docPtr: number,
|
||||
pageIndex: number,
|
||||
scale: number,
|
||||
options: RenderPdfiumPageOptions = {},
|
||||
): Promise<string | null> {
|
||||
const { applyRotation = true, format = "png", quality } = options;
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) return null;
|
||||
|
||||
try {
|
||||
// Effective dims already account for /Rotate; PDFium auto-applies it during render.
|
||||
const rawW = m.FPDF_GetPageWidthF(pagePtr);
|
||||
const rawH = m.FPDF_GetPageHeightF(pagePtr);
|
||||
// Returns 0–3 for 0°/90°/180°/270° CW.
|
||||
const pageRotQuarters = (m as any).FPDFPage_GetRotation(pagePtr) | 0;
|
||||
|
||||
const isQuarterTurn = pageRotQuarters === 1 || pageRotQuarters === 3;
|
||||
|
||||
// false: swap dims back to raw + counter-rotate to cancel PDFium's auto /Rotate.
|
||||
const outW = applyRotation ? rawW : isQuarterTurn ? rawH : rawW;
|
||||
const outH = applyRotation ? rawH : isQuarterTurn ? rawW : rawH;
|
||||
const renderRotate = applyRotation ? 0 : (4 - pageRotQuarters) % 4;
|
||||
|
||||
const w = Math.max(1, Math.round(outW * scale));
|
||||
const h = Math.max(1, Math.round(outH * scale));
|
||||
|
||||
const bitmapPtr = m.FPDFBitmap_Create(w, h, 1);
|
||||
try {
|
||||
// White background; PDF content doesn't encode paper colour.
|
||||
m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff);
|
||||
m.FPDF_RenderPageBitmap(
|
||||
bitmapPtr,
|
||||
pagePtr,
|
||||
0,
|
||||
0,
|
||||
w,
|
||||
h,
|
||||
renderRotate,
|
||||
PDFIUM_RENDER_FLAGS,
|
||||
);
|
||||
|
||||
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
|
||||
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
|
||||
const heap = new Uint8Array((m.pdfium.wasmExports as any).memory.buffer);
|
||||
const pixels = new Uint8ClampedArray(w * h * 4);
|
||||
|
||||
// @embedpdf/pdfium WASM stores pixels in RGBA byte order (not BGRA),
|
||||
// so copy rows directly without channel swapping.
|
||||
for (let y = 0; y < h; y++) {
|
||||
const srcRow = bufferPtr + y * stride;
|
||||
pixels.set(heap.subarray(srcRow, srcRow + w * 4), y * w * 4);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.putImageData(new ImageData(pixels, w, h), 0, 0);
|
||||
return format === "jpeg"
|
||||
? canvas.toDataURL("image/jpeg", quality ?? 0.8)
|
||||
: canvas.toDataURL();
|
||||
} finally {
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
}
|
||||
} finally {
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw width/height/rotation for a page without rendering.
|
||||
*/
|
||||
export async function readPdfiumPageMetadata(
|
||||
docPtr: number,
|
||||
pageIndex: number,
|
||||
): Promise<{ width: number; height: number; rotation: number } | null> {
|
||||
const m = await getPdfiumModule();
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) return null;
|
||||
try {
|
||||
const width = m.FPDF_GetPageWidthF(pagePtr);
|
||||
const height = m.FPDF_GetPageHeightF(pagePtr);
|
||||
const rotation = (((m as any).FPDFPage_GetRotation(pagePtr) | 0) & 3) * 90;
|
||||
return { width, height, rotation };
|
||||
} finally {
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utilities for managing file resources and blob URLs
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
|
||||
|
||||
export class ResourceManager {
|
||||
private static blobUrls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Create a blob URL and track it for cleanup
|
||||
*/
|
||||
static createBlobUrl(blob: Blob): string {
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.blobUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific blob URL
|
||||
*/
|
||||
static revokeBlobUrl(url: string): void {
|
||||
if (this.blobUrls.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.blobUrls.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tracked blob URLs
|
||||
*/
|
||||
static revokeAllBlobUrls(): void {
|
||||
this.blobUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
this.blobUrls.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File with proper naming convention
|
||||
*/
|
||||
static createResultFile(
|
||||
data: BlobPart,
|
||||
originalName: string,
|
||||
prefix: string = AUTOMATION_CONSTANTS.PROCESSED_FILE_PREFIX,
|
||||
type: string = "application/pdf",
|
||||
): File {
|
||||
return new File([data], `${prefix}${originalName}`, { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timestamped file for responses
|
||||
*/
|
||||
static createTimestampedFile(
|
||||
data: BlobPart,
|
||||
prefix: string,
|
||||
extension: string = ".pdf",
|
||||
type: string = "application/pdf",
|
||||
): File {
|
||||
const timestamp = Date.now();
|
||||
return new File([data], `${prefix}${timestamp}${extension}`, { type });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for automatic cleanup on component unmount
|
||||
*/
|
||||
export function useResourceCleanup(): () => void {
|
||||
return useCallback(() => {
|
||||
ResourceManager.revokeAllBlobUrls();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Scarf analytics pixel tracking utility
|
||||
*
|
||||
* This module provides a firePixel function that can be called from anywhere,
|
||||
* including non-React utility functions. Configuration and consent state are
|
||||
* injected via setScarfConfig() which should be called from a React hook
|
||||
* during app initialization.
|
||||
*
|
||||
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
|
||||
* The initialization hook (useScarfTracking) is mounted in App.tsx.
|
||||
*
|
||||
* For testing: Use resetScarfConfig() to clear module state between tests.
|
||||
*/
|
||||
|
||||
// Module-level state
|
||||
let configured: boolean = false;
|
||||
let enableScarf: boolean | null = null;
|
||||
let isServiceAccepted: ((service: string, category: string) => boolean) | null =
|
||||
null;
|
||||
let lastFiredPathname: string | null = null;
|
||||
let lastFiredTime = 0;
|
||||
|
||||
/**
|
||||
* Configure scarf tracking with app config and consent checker
|
||||
* Should be called from a React hook during app initialization (see useScarfTracking)
|
||||
*
|
||||
* @param scarfEnabled - Whether scarf tracking is enabled globally
|
||||
* @param consentChecker - Function to check if user has accepted scarf service
|
||||
*/
|
||||
export function setScarfConfig(
|
||||
scarfEnabled: boolean | null,
|
||||
consentChecker: (service: string, category: string) => boolean,
|
||||
): void {
|
||||
configured = true;
|
||||
enableScarf = scarfEnabled;
|
||||
isServiceAccepted = consentChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire scarf pixel for analytics tracking
|
||||
* Only fires if:
|
||||
* - Scarf tracking has been initialized via setScarfConfig()
|
||||
* - Scarf is globally enabled in config
|
||||
* - User has accepted scarf service via cookie consent
|
||||
* - Pathname has changed or enough time has passed since last fire
|
||||
*
|
||||
* @param pathname - The pathname to track (usually window.location.pathname)
|
||||
*/
|
||||
export function firePixel(pathname: string): void {
|
||||
// Dev-mode warning if called before initialization
|
||||
if (!configured) {
|
||||
console.warn(
|
||||
"[scarfTracking] firePixel() called before setScarfConfig(). " +
|
||||
"Ensure useScarfTracking() hook is mounted in App.tsx.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Scarf is globally disabled
|
||||
if (enableScarf === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if consent checker is available and scarf service is accepted
|
||||
if (!isServiceAccepted || !isServiceAccepted("scarf", "analytics")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Only fire if pathname changed or it's been at least 250ms since last fire
|
||||
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastFiredPathname = pathname;
|
||||
lastFiredTime = now;
|
||||
|
||||
const url =
|
||||
"https://static.scarf.sh/a.png?x-pxid=3c1d68de-8945-4e9f-873f-65320b6fabf7" +
|
||||
"&path=" +
|
||||
encodeURIComponent(pathname);
|
||||
|
||||
const img = new Image();
|
||||
img.referrerPolicy = "no-referrer-when-downgrade";
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset scarf tracking configuration and state
|
||||
* Useful for testing to ensure clean state between test runs
|
||||
*/
|
||||
export function resetScarfConfig(): void {
|
||||
enableScarf = null;
|
||||
isServiceAccepted = null;
|
||||
lastFiredPathname = null;
|
||||
lastFiredTime = 0;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility for dynamically loading external scripts
|
||||
*/
|
||||
|
||||
interface ScriptLoadOptions {
|
||||
src: string;
|
||||
id?: string;
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const loadedScripts = new Set<string>();
|
||||
|
||||
export function loadScript({
|
||||
src,
|
||||
id,
|
||||
async = true,
|
||||
defer = false,
|
||||
onLoad,
|
||||
}: ScriptLoadOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
const scriptId = id || src;
|
||||
if (loadedScripts.has(scriptId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script already exists in DOM
|
||||
const existingScript = id
|
||||
? document.getElementById(id)
|
||||
: document.querySelector(`script[src="${src}"]`);
|
||||
if (existingScript) {
|
||||
loadedScripts.add(scriptId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and append script
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
if (id) script.id = id;
|
||||
script.async = async;
|
||||
script.defer = defer;
|
||||
|
||||
script.onload = () => {
|
||||
loadedScripts.add(scriptId);
|
||||
if (onLoad) onLoad();
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function isScriptLoaded(idOrSrc: string): boolean {
|
||||
return loadedScripts.has(idOrSrc);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings section
|
||||
*
|
||||
* @param section - The settings section key to navigate to
|
||||
*
|
||||
* @example
|
||||
* // Navigate to People section
|
||||
* navigateToSettings('people');
|
||||
*
|
||||
* // Navigate to Admin Premium section
|
||||
* navigateToSettings('adminPremium');
|
||||
*/
|
||||
export function navigateToSettings(section: NavKey) {
|
||||
const basePath = window.location.pathname.split("/settings")[0] || "";
|
||||
const newPath = `${basePath}/settings/${section}`;
|
||||
window.history.pushState({}, "", newPath);
|
||||
|
||||
// Trigger a popstate event to notify components
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL path for a settings section
|
||||
* Useful for creating links
|
||||
*
|
||||
* @param section - The settings section key
|
||||
* @returns The URL path for the settings section
|
||||
*
|
||||
* @example
|
||||
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
|
||||
* // Returns: "/settings/people"
|
||||
*/
|
||||
export function getSettingsUrl(section: NavKey): string {
|
||||
return `/settings/${section}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently viewing a settings section
|
||||
*
|
||||
* @param section - Optional section key to check for specific section
|
||||
* @returns True if in settings (and matching specific section if provided)
|
||||
*/
|
||||
export function isInSettings(section?: NavKey): boolean {
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
if (!section) {
|
||||
return pathname.startsWith("/settings");
|
||||
}
|
||||
|
||||
return pathname === `/settings/${section}`;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Helper utilities for handling settings with pending changes that require restart.
|
||||
*
|
||||
* Backend returns settings in this format:
|
||||
* {
|
||||
* "enableLogin": false, // Current active value
|
||||
* "csrfDisabled": true,
|
||||
* "_pending": { // Optional - only present if there are pending changes
|
||||
* "enableLogin": true // Value that will be active after restart
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
export interface SettingsWithPending<T = any> {
|
||||
_pending?: Partial<T>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge pending changes into the settings object.
|
||||
* Returns a new object with pending values overlaid on top of current values.
|
||||
*
|
||||
* @param settings Settings object from backend (may contain _pending block)
|
||||
* @returns Merged settings with pending values applied
|
||||
*/
|
||||
export function mergePendingSettings<T extends SettingsWithPending>(
|
||||
settings: T,
|
||||
): Omit<T, "_pending"> {
|
||||
if (!settings || !settings._pending) {
|
||||
// No pending changes, return as-is (without _pending property)
|
||||
const { _pending, ...rest } = settings || {};
|
||||
return rest as Omit<T, "_pending">;
|
||||
}
|
||||
|
||||
// Deep merge pending changes
|
||||
const merged = deepMerge(settings, settings._pending);
|
||||
|
||||
// Remove _pending from result
|
||||
const { _pending, ...result } = merged;
|
||||
return result as Omit<T, "_pending">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific field has a pending change awaiting restart.
|
||||
*
|
||||
* @param settings Settings object from backend
|
||||
* @param fieldPath Dot-notation path to the field (e.g., "oauth2.clientSecret")
|
||||
* @returns True if field has pending changes
|
||||
*/
|
||||
export function isFieldPending<T extends SettingsWithPending>(
|
||||
settings: T | null | undefined,
|
||||
fieldPath: string,
|
||||
): boolean {
|
||||
if (!settings?._pending) {
|
||||
console.log(
|
||||
`[isFieldPending] No _pending block found for field: ${fieldPath}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Navigate the pending object using dot notation
|
||||
const value = getNestedValue(settings._pending, fieldPath);
|
||||
const isPending = value !== undefined;
|
||||
|
||||
if (isPending) {
|
||||
console.log(
|
||||
`[isFieldPending] Field ${fieldPath} IS pending with value:`,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
return isPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any pending changes in the settings.
|
||||
*
|
||||
* @param settings Settings object from backend
|
||||
* @returns True if there are any pending changes
|
||||
*/
|
||||
export function hasPendingChanges<T extends SettingsWithPending>(
|
||||
settings: T | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
settings?._pending !== undefined &&
|
||||
Object.keys(settings._pending).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending value for a specific field, or undefined if no pending change.
|
||||
*
|
||||
* @param settings Settings object from backend
|
||||
* @param fieldPath Dot-notation path to the field
|
||||
* @returns Pending value or undefined
|
||||
*/
|
||||
export function getPendingValue<T extends SettingsWithPending>(
|
||||
settings: T | null | undefined,
|
||||
fieldPath: string,
|
||||
): any {
|
||||
if (!settings?._pending) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getNestedValue(settings._pending, fieldPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active value for a field (ignoring pending changes).
|
||||
*
|
||||
* @param settings Settings object from backend
|
||||
* @param fieldPath Dot-notation path to the field
|
||||
* @returns Current active value
|
||||
*/
|
||||
export function getCurrentValue<T extends SettingsWithPending>(
|
||||
settings: T | null | undefined,
|
||||
fieldPath: string,
|
||||
): any {
|
||||
if (!settings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get from settings, ignoring _pending
|
||||
const { _pending, ...activeSettings } = settings;
|
||||
return getNestedValue(activeSettings, fieldPath);
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Deep merge two objects. Second object takes priority.
|
||||
*/
|
||||
function deepMerge(target: any, source: any): any {
|
||||
if (!source) return target;
|
||||
if (!target) return source;
|
||||
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
||||
result[key] = deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value using dot notation.
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj || !path) return undefined;
|
||||
|
||||
const parts = path.split(".");
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is a plain object (not array, not null, not Date, etc.)
|
||||
*/
|
||||
function isPlainObject(value: any): boolean {
|
||||
return (
|
||||
value !== null && typeof value === "object" && value.constructor === Object
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SidebarRefs, SidebarState, SidebarInfo } from "@app/types/sidebar";
|
||||
|
||||
/**
|
||||
* Gets the All tools sidebar information using React refs and state
|
||||
* @param refs - Object containing refs to sidebar elements
|
||||
* @param state - Current sidebar state
|
||||
* @returns Object containing the sidebar rect and whether the tool panel is active
|
||||
*/
|
||||
export function getSidebarInfo(
|
||||
refs: SidebarRefs,
|
||||
state: SidebarState,
|
||||
): SidebarInfo {
|
||||
const { quickAccessRef, toolPanelRef } = refs;
|
||||
const { sidebarsVisible, readerMode } = state;
|
||||
|
||||
// Determine if tool panel should be active based on state
|
||||
const isToolPanelActive = sidebarsVisible && !readerMode;
|
||||
|
||||
let rect: DOMRect | null = null;
|
||||
|
||||
if (isToolPanelActive && toolPanelRef.current) {
|
||||
// Tool panel is expanded: use its rect
|
||||
rect = toolPanelRef.current.getBoundingClientRect();
|
||||
} else if (quickAccessRef.current) {
|
||||
// Fall back to quick access bar
|
||||
// This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this
|
||||
rect = quickAccessRef.current.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return {
|
||||
rect,
|
||||
isToolPanelActive,
|
||||
sidebarState: state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// PDFium annotation subtype constants
|
||||
import {
|
||||
FPDF_ANNOT_INK,
|
||||
FPDF_ANNOT_LINE,
|
||||
embedBitmapImageOnPage,
|
||||
drawPlaceholderRect,
|
||||
decodeImageDataUrl,
|
||||
} from "@app/utils/pdfiumBitmapUtils";
|
||||
import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils";
|
||||
import {
|
||||
createChildStub,
|
||||
createProcessedFile,
|
||||
} from "@app/contexts/file/fileActions";
|
||||
import {
|
||||
createStirlingFile,
|
||||
FileId,
|
||||
StirlingFile,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { SignatureAPI } from "@app/components/viewer/viewerTypes";
|
||||
import {
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
closeDocAndFreeBuffer,
|
||||
saveRawDocument,
|
||||
} from "@app/services/pdfiumService";
|
||||
|
||||
interface MinimalFileContextSelectors {
|
||||
getAllFileIds: () => FileId[];
|
||||
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
|
||||
getFile: (id: FileId) => StirlingFile | undefined;
|
||||
}
|
||||
|
||||
interface SignatureFlatteningOptions {
|
||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
exportActions?: {
|
||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||
};
|
||||
selectors: MinimalFileContextSelectors;
|
||||
originalFile?: StirlingFile;
|
||||
getScrollState: () => { currentPage: number; totalPages: number };
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export interface SignatureFlatteningResult {
|
||||
inputFileIds: FileId[];
|
||||
outputStirlingFile: StirlingFile;
|
||||
outputStub: StirlingFileStub;
|
||||
}
|
||||
|
||||
export async function flattenSignatures(
|
||||
options: SignatureFlatteningOptions,
|
||||
): Promise<SignatureFlatteningResult | null> {
|
||||
const {
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
exportActions,
|
||||
selectors,
|
||||
originalFile,
|
||||
getScrollState,
|
||||
activeFileIndex,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Step 1: Extract all annotations from EmbedPDF before export
|
||||
const allAnnotations: Array<{ pageIndex: number; annotations: any[] }> = [];
|
||||
|
||||
if (signatureApiRef?.current) {
|
||||
const scrollState = getScrollState();
|
||||
const totalPages = scrollState.totalPages;
|
||||
|
||||
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
try {
|
||||
const pageAnnotations =
|
||||
await signatureApiRef.current.getPageAnnotations(pageIndex);
|
||||
if (pageAnnotations && pageAnnotations.length > 0) {
|
||||
const sessionAnnotations = pageAnnotations.filter((annotation) => {
|
||||
const hasStoredImageData =
|
||||
annotation.id && getImageData(annotation.id);
|
||||
const hasDirectImageData =
|
||||
annotation.imageData ||
|
||||
annotation.appearance ||
|
||||
annotation.stampData ||
|
||||
annotation.imageSrc ||
|
||||
annotation.contents ||
|
||||
annotation.data;
|
||||
return (
|
||||
hasStoredImageData ||
|
||||
(hasDirectImageData &&
|
||||
typeof hasDirectImageData === "string" &&
|
||||
hasDirectImageData.startsWith("data:image"))
|
||||
);
|
||||
});
|
||||
|
||||
if (sessionAnnotations.length > 0) {
|
||||
allAnnotations.push({
|
||||
pageIndex,
|
||||
annotations: sessionAnnotations,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (pageError) {
|
||||
console.warn(
|
||||
`Error extracting annotations from page ${pageIndex + 1}:`,
|
||||
pageError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Delete ONLY session annotations from EmbedPDF before export
|
||||
if (allAnnotations.length > 0 && signatureApiRef?.current) {
|
||||
for (const pageData of allAnnotations) {
|
||||
for (const annotation of pageData.annotations) {
|
||||
try {
|
||||
signatureApiRef.current.deleteAnnotation(
|
||||
annotation.id,
|
||||
pageData.pageIndex,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.warn(
|
||||
`Failed to delete annotation ${annotation.id}:`,
|
||||
deleteError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF
|
||||
if (!exportActions) {
|
||||
console.error("No export actions available");
|
||||
return null;
|
||||
}
|
||||
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
||||
|
||||
if (pdfArrayBuffer) {
|
||||
const blob = new Blob([pdfArrayBuffer], { type: "application/pdf" });
|
||||
|
||||
let currentFile = originalFile;
|
||||
if (!currentFile) {
|
||||
const allFileIds = selectors.getAllFileIds();
|
||||
if (allFileIds.length > 0) {
|
||||
const fileIndex =
|
||||
activeFileIndex !== undefined && activeFileIndex < allFileIds.length
|
||||
? activeFileIndex
|
||||
: 0;
|
||||
const fileStub = selectors.getStirlingFileStub(allFileIds[fileIndex]);
|
||||
const fileObject = selectors.getFile(allFileIds[fileIndex]);
|
||||
if (fileStub && fileObject) {
|
||||
currentFile = createStirlingFile(
|
||||
fileObject,
|
||||
allFileIds[fileIndex] as FileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentFile) {
|
||||
console.error("No file available to replace");
|
||||
return null;
|
||||
}
|
||||
|
||||
let signedFile = new File([blob], currentFile.name, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
// Step 4: Manually render extracted annotations onto the PDF using PDFium WASM
|
||||
if (allAnnotations.length > 0) {
|
||||
try {
|
||||
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
|
||||
|
||||
try {
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
for (const pageData of allAnnotations) {
|
||||
const { pageIndex, annotations } = pageData;
|
||||
|
||||
if (pageIndex < pageCount) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) continue;
|
||||
|
||||
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
|
||||
|
||||
for (const annotation of annotations) {
|
||||
try {
|
||||
const rect =
|
||||
annotation.rect ||
|
||||
annotation.bounds ||
|
||||
annotation.rectangle ||
|
||||
annotation.position;
|
||||
|
||||
if (rect) {
|
||||
const originalX =
|
||||
rect.origin?.x || rect.x || rect.left || 0;
|
||||
const originalY =
|
||||
rect.origin?.y || rect.y || rect.top || 0;
|
||||
const width = rect.size?.width || rect.width || 100;
|
||||
const height = rect.size?.height || rect.height || 50;
|
||||
|
||||
// Convert from CSS top-left to PDF bottom-left
|
||||
const pdfX = originalX;
|
||||
const pdfY = pageHeight - originalY - height;
|
||||
|
||||
let imageDataUrl =
|
||||
annotation.imageData ||
|
||||
annotation.appearance ||
|
||||
annotation.stampData ||
|
||||
annotation.imageSrc ||
|
||||
annotation.contents ||
|
||||
annotation.data;
|
||||
|
||||
if (!imageDataUrl && annotation.id) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (storedImageData) {
|
||||
imageDataUrl = storedImageData;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert SVG to PNG first if needed
|
||||
if (
|
||||
imageDataUrl &&
|
||||
typeof imageDataUrl === "string" &&
|
||||
imageDataUrl.startsWith("data:image/svg+xml")
|
||||
) {
|
||||
const pngBytes = await rasteriseSvgToPng(
|
||||
imageDataUrl,
|
||||
width * 2,
|
||||
height * 2,
|
||||
);
|
||||
if (pngBytes) {
|
||||
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
|
||||
} else {
|
||||
drawPlaceholderRect(
|
||||
m,
|
||||
pagePtr,
|
||||
pdfX,
|
||||
pdfY,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
imageDataUrl &&
|
||||
typeof imageDataUrl === "string" &&
|
||||
imageDataUrl.startsWith("data:image")
|
||||
) {
|
||||
// Decode the image data URL to raw pixels via canvas
|
||||
const imageResult =
|
||||
await decodeImageDataUrl(imageDataUrl);
|
||||
if (imageResult) {
|
||||
embedBitmapImageOnPage(
|
||||
m,
|
||||
docPtr,
|
||||
pagePtr,
|
||||
imageResult,
|
||||
pdfX,
|
||||
pdfY,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
annotation.type === FPDF_ANNOT_INK ||
|
||||
annotation.type === FPDF_ANNOT_LINE
|
||||
) {
|
||||
drawPlaceholderRect(
|
||||
m,
|
||||
pagePtr,
|
||||
pdfX,
|
||||
pdfY,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (annotationError) {
|
||||
console.warn(
|
||||
"Failed to render annotation:",
|
||||
annotationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
m.FPDFPage_GenerateContent(pagePtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
}
|
||||
|
||||
const resultBuf = await saveRawDocument(docPtr);
|
||||
signedFile = new File([resultBuf], currentFile.name, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
} catch (renderError) {
|
||||
console.error("Failed to manually render annotations:", renderError);
|
||||
console.warn("Signatures may only show as annotations");
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
|
||||
const processedFileMetadata = createProcessedFile(
|
||||
thumbnailResult.pageCount,
|
||||
thumbnailResult.thumbnail,
|
||||
);
|
||||
|
||||
const inputFileIds: FileId[] = [currentFile.fileId];
|
||||
|
||||
const record = selectors.getStirlingFileStub(currentFile.fileId);
|
||||
if (!record) {
|
||||
console.error("No file record found for:", currentFile.fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputStub = createChildStub(
|
||||
record,
|
||||
{ toolId: "sign", timestamp: Date.now() },
|
||||
signedFile,
|
||||
thumbnailResult.thumbnail,
|
||||
processedFileMetadata,
|
||||
);
|
||||
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
|
||||
|
||||
return {
|
||||
inputFileIds,
|
||||
outputStirlingFile,
|
||||
outputStub,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error flattening signatures:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
|
||||
*/
|
||||
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const blob = new Blob([pngBytes as BlobPart], { type: "image/png" });
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterise an SVG data URL to PNG bytes via an offscreen canvas.
|
||||
*/
|
||||
function rasteriseSvgToPng(
|
||||
svgDataUrl: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<Uint8Array | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then(
|
||||
(buf) => resolve(new Uint8Array(buf)),
|
||||
() => resolve(null),
|
||||
);
|
||||
}, "image/png");
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = svgDataUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||
import {
|
||||
HORIZONTAL_PADDING_RATIO,
|
||||
VERTICAL_PADDING_RATIO,
|
||||
} from "@app/constants/signConstants";
|
||||
|
||||
export interface SignaturePreview {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
export const buildSignaturePreview = async (
|
||||
config: SignParameters | null,
|
||||
): Promise<SignaturePreview | null> => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.signatureType === "text") {
|
||||
const text = config.signerName?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? "Helvetica";
|
||||
const textColor = config.textColor ?? "#000000";
|
||||
|
||||
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
|
||||
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
|
||||
|
||||
const measureCanvas = document.createElement("canvas");
|
||||
const measureCtx = measureCanvas.getContext("2d");
|
||||
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
|
||||
const width = Math.max(1, textWidth + paddingX * 2);
|
||||
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(text, paddingX, height / 2);
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
return { dataUrl, width, height };
|
||||
}
|
||||
|
||||
const dataUrl = config.signatureData;
|
||||
if (!dataUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = await loadImage(dataUrl);
|
||||
return {
|
||||
dataUrl,
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { StorageStats } from "@app/services/fileStorage";
|
||||
|
||||
/**
|
||||
* Storage operation types for incremental updates
|
||||
*/
|
||||
export type StorageOperation = "add" | "remove" | "clear";
|
||||
|
||||
/**
|
||||
* Update storage stats incrementally based on operation
|
||||
*/
|
||||
export function updateStorageStatsIncremental(
|
||||
currentStats: StorageStats,
|
||||
operation: StorageOperation,
|
||||
files: File[] = [],
|
||||
): StorageStats {
|
||||
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
|
||||
|
||||
switch (operation) {
|
||||
case "add":
|
||||
return {
|
||||
...currentStats,
|
||||
used: currentStats.used + filesSizeTotal,
|
||||
available: currentStats.available - filesSizeTotal,
|
||||
fileCount: currentStats.fileCount + files.length,
|
||||
};
|
||||
|
||||
case "remove":
|
||||
return {
|
||||
...currentStats,
|
||||
used: Math.max(0, currentStats.used - filesSizeTotal),
|
||||
available: currentStats.available + filesSizeTotal,
|
||||
fileCount: Math.max(0, currentStats.fileCount - files.length),
|
||||
};
|
||||
|
||||
case "clear":
|
||||
return {
|
||||
...currentStats,
|
||||
used: 0,
|
||||
available: currentStats.quota || currentStats.available,
|
||||
fileCount: 0,
|
||||
};
|
||||
|
||||
default:
|
||||
return currentStats;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check storage usage and return warning message if needed
|
||||
*/
|
||||
export function checkStorageWarnings(stats: StorageStats): string | null {
|
||||
if (!stats.quota || stats.used === 0) return null;
|
||||
|
||||
const usagePercent = (stats.used / stats.quota) * 100;
|
||||
|
||||
if (usagePercent > 90) {
|
||||
return "Warning: Storage is nearly full (>90%). Browser may start clearing data.";
|
||||
} else if (usagePercent > 80) {
|
||||
return "Storage is getting full (>80%). Consider removing old files.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate storage usage percentage
|
||||
*/
|
||||
export function getStorageUsagePercent(stats: StorageStats): number {
|
||||
return stats.quota ? (stats.used / stats.quota) * 100 : 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Shared text Comparison and normalization utilities for compare tool
|
||||
|
||||
export const shouldConcatWithoutSpace = (word: string) => {
|
||||
return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s";
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) return word;
|
||||
if (shouldConcatWithoutSpace(word)) return `${existing}${word}`;
|
||||
return `${existing} ${word}`;
|
||||
};
|
||||
export const tokenize = (text: string): string[] =>
|
||||
text.split(/\s+/).filter(Boolean);
|
||||
|
||||
type TokenType = "unchanged" | "removed" | "added";
|
||||
export interface LocalToken {
|
||||
type: TokenType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const buildLcsMatrix = (a: string[], b: string[]) => {
|
||||
const rows = a.length + 1;
|
||||
const cols = b.length + 1;
|
||||
const m: number[][] = new Array(rows);
|
||||
for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0);
|
||||
for (let i = 1; i < rows; i += 1) {
|
||||
for (let j = 1; j < cols; j += 1) {
|
||||
m[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? m[i - 1][j - 1] + 1
|
||||
: Math.max(m[i][j - 1], m[i - 1][j]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
export const diffWords = (a: string[], b: string[]): LocalToken[] => {
|
||||
const matrix = buildLcsMatrix(a, b);
|
||||
const tokens: LocalToken[] = [];
|
||||
let i = a.length;
|
||||
let j = b.length;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
||||
tokens.unshift({ type: "unchanged", text: a[i - 1] });
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
|
||||
tokens.unshift({ type: "added", text: b[j - 1] });
|
||||
j -= 1;
|
||||
} else if (i > 0) {
|
||||
tokens.unshift({ type: "removed", text: a[i - 1] });
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Truncates text from the centre, preserving the start and end.
|
||||
* e.g. "very-long-filename.pdf" -> "very-lo...ame.pdf"
|
||||
*/
|
||||
export function truncateCenter(text: string, maxLength: number = 25): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const ellipsis = "...";
|
||||
const charsToShow = maxLength - ellipsis.length;
|
||||
const frontChars = Math.ceil(charsToShow / 2);
|
||||
const backChars = Math.floor(charsToShow / 2);
|
||||
return (
|
||||
text.substring(0, frontChars) +
|
||||
ellipsis +
|
||||
text.substring(text.length - backChars)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out emoji characters from a text string
|
||||
* @param text - The input text string
|
||||
* @returns The filtered text without emoji characters
|
||||
*/
|
||||
export const removeEmojis = (text: string): string => {
|
||||
// Filter out emoji characters (Unicode ranges for emojis)
|
||||
return text.replace(
|
||||
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu,
|
||||
"",
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
openRawDocumentSafe,
|
||||
closeRawDocument,
|
||||
getPdfiumModule,
|
||||
} from "@app/services/pdfiumService";
|
||||
import {
|
||||
renderPdfiumPageDataUrl,
|
||||
readPdfiumPageMetadata,
|
||||
} from "@app/utils/pdfiumPageRender";
|
||||
|
||||
export interface ThumbnailWithMetadata {
|
||||
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
|
||||
pageCount: number;
|
||||
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
|
||||
pageDimensions?: Array<{ width: number; height: number }>;
|
||||
isEncrypted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate thumbnail scale based on file size (modern 2024 scaling)
|
||||
*/
|
||||
export function calculateScaleFromFileSize(fileSize: number): number {
|
||||
const MB = 1024 * 1024;
|
||||
if (fileSize < 10 * MB) return 1.0; // Full quality for small files
|
||||
if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes
|
||||
if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files
|
||||
if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files
|
||||
return 0.3; // Still usable quality, not tiny
|
||||
}
|
||||
|
||||
/** PDFium error code 4 = password required (encrypted PDF). */
|
||||
const PDFIUM_ERR_PASSWORD = 4;
|
||||
|
||||
interface PdfiumRenderResult {
|
||||
thumbnail: string;
|
||||
pageCount: number;
|
||||
pageRotations: number[];
|
||||
pageDimensions: Array<{ width: number; height: number }>;
|
||||
/** Set when the document is password-protected — caller substitutes the
|
||||
* encrypted placeholder. Thumbnail/metadata fields are empty in that case. */
|
||||
isEncrypted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a PDF with PDFium, render page 1 to a data URL, and optionally
|
||||
* collect rotation + dimensions for every page. Returns `isEncrypted: true`
|
||||
* (without rendering) when the document is password-protected.
|
||||
*
|
||||
* @param applyRotation When true, bakes the page's own rotation into the
|
||||
* bitmap (static display). When false, renders upright so callers can
|
||||
* apply rotation via CSS (PageEditor).
|
||||
* @param collectAllPagesMetadata When true, reads per-page rotation and
|
||||
* dimensions for all pages. When false (very large files), only the
|
||||
* first page's metadata is populated.
|
||||
*/
|
||||
async function renderPdfThumbnailPdfium(
|
||||
data: ArrayBuffer,
|
||||
scale: number,
|
||||
applyRotation: boolean,
|
||||
collectAllPagesMetadata: boolean,
|
||||
): Promise<PdfiumRenderResult> {
|
||||
const m = await getPdfiumModule();
|
||||
let docPtr: number;
|
||||
try {
|
||||
docPtr = await openRawDocumentSafe(data);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
new RegExp(`error ${PDFIUM_ERR_PASSWORD}`).test(error.message)
|
||||
) {
|
||||
return {
|
||||
thumbnail: "",
|
||||
pageCount: 1,
|
||||
pageRotations: [],
|
||||
pageDimensions: [],
|
||||
isEncrypted: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
const thumbnail = await renderPdfiumPageDataUrl(docPtr, 0, scale, {
|
||||
applyRotation,
|
||||
});
|
||||
if (!thumbnail) throw new Error("PDFium: failed to render page 0");
|
||||
|
||||
// Page 0 metadata is already available via the render, but read it
|
||||
// directly for consistency with the later per-page loop.
|
||||
const firstMeta = await readPdfiumPageMetadata(docPtr, 0);
|
||||
const pageRotations: number[] = [firstMeta?.rotation ?? 0];
|
||||
const pageDimensions: Array<{ width: number; height: number }> = [
|
||||
{
|
||||
width: firstMeta?.width ?? 0,
|
||||
height: firstMeta?.height ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
if (collectAllPagesMetadata) {
|
||||
for (let i = 1; i < pageCount; i++) {
|
||||
const meta = await readPdfiumPageMetadata(docPtr, i);
|
||||
if (!meta) continue;
|
||||
pageRotations[i] = meta.rotation;
|
||||
pageDimensions[i] = { width: meta.width, height: meta.height };
|
||||
}
|
||||
}
|
||||
|
||||
return { thumbnail, pageCount, pageRotations, pageDimensions };
|
||||
} finally {
|
||||
await closeRawDocument(docPtr);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePDFThumbnail(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
scale: number,
|
||||
): Promise<string> {
|
||||
const result = await renderPdfThumbnailPdfium(
|
||||
arrayBuffer,
|
||||
scale,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
if (result.isEncrypted) {
|
||||
return "";
|
||||
}
|
||||
return result.thumbnail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail for any file type - always returns a thumbnail (placeholder if needed)
|
||||
*/
|
||||
export async function generateThumbnailForFile(file: File): Promise<string> {
|
||||
// Very large PDFs skip thumbnail generation — SVG icon shown in UI instead
|
||||
if (file.size >= 100 * 1024 * 1024) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle image files - convert to data URL for persistence
|
||||
if (file.type.startsWith("image/")) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle PDF files
|
||||
if (file.type.startsWith("application/pdf")) {
|
||||
const scale = calculateScaleFromFileSize(file.size);
|
||||
|
||||
// Only read first 2MB for thumbnail generation to save memory
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
|
||||
try {
|
||||
return await generatePDFThumbnail(arrayBuffer, scale);
|
||||
} catch {
|
||||
// PDFium needs the xref table at the end of the file, so the 2MB
|
||||
// chunk can fail to open for PDFs larger than that. Retry with the
|
||||
// full buffer before falling back to an empty thumbnail.
|
||||
try {
|
||||
const fullArrayBuffer = await file.arrayBuffer();
|
||||
return await generatePDFThumbnail(fullArrayBuffer, scale);
|
||||
} catch (error) {
|
||||
console.warn(`PDF processing failed for ${file.name}:`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-PDF, non-image files use scalable SVG icons in the UI — no raster thumbnail needed
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail
|
||||
* @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display).
|
||||
* If false, render without rotation (for CSS-based rotation in PageEditor)
|
||||
*/
|
||||
export async function generateThumbnailWithMetadata(
|
||||
file: File,
|
||||
applyRotation: boolean = true,
|
||||
): Promise<ThumbnailWithMetadata> {
|
||||
// Non-PDF files have no page count
|
||||
if (!file.type.startsWith("application/pdf")) {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return { thumbnail, pageCount: 0 };
|
||||
}
|
||||
|
||||
const scale = calculateScaleFromFileSize(file.size);
|
||||
const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await renderPdfThumbnailPdfium(
|
||||
arrayBuffer,
|
||||
scale,
|
||||
applyRotation,
|
||||
!isVeryLarge,
|
||||
);
|
||||
|
||||
if (result.isEncrypted) {
|
||||
return {
|
||||
thumbnail: "",
|
||||
pageCount: 1,
|
||||
isEncrypted: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
thumbnail: result.thumbnail,
|
||||
pageCount: result.pageCount,
|
||||
pageRotations: result.pageRotations,
|
||||
pageDimensions: result.pageDimensions,
|
||||
};
|
||||
} catch {
|
||||
return { thumbnail: "", pageCount: 1 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Standardized error handling utilities for tool operations
|
||||
*/
|
||||
|
||||
import { normalizeAxiosErrorData } from "@app/services/errorUtils";
|
||||
|
||||
/**
|
||||
* Default error extractor that follows the standard pattern
|
||||
*/
|
||||
export const extractErrorMessage = (error: any): string => {
|
||||
if (error.response?.data && typeof error.response.data === "string") {
|
||||
return error.response.data;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return "There was an error processing your request.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standardized error handler for tool operations
|
||||
* @param fallbackMessage - Message to show when no specific error can be extracted
|
||||
* @returns Error handler function that follows the standard pattern
|
||||
*/
|
||||
export const createStandardErrorHandler = (fallbackMessage: string) => {
|
||||
return (error: any): string => {
|
||||
if (error.response?.data && typeof error.response.data === "string") {
|
||||
return error.response.data;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a 422 response, extracts errored file IDs from the payload (JSON or UUID regex),
|
||||
* and marks them in the UI. Returns true if IDs were found and handled, false otherwise.
|
||||
*/
|
||||
export const handle422Error = async (
|
||||
error: any,
|
||||
markFileError: (fileId: string) => void,
|
||||
): Promise<boolean> => {
|
||||
const status = error?.response?.status;
|
||||
if (typeof status !== "number" || status !== 422) return false;
|
||||
|
||||
const payload = error?.response?.data;
|
||||
let parsed: unknown = payload;
|
||||
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
parsed = payload;
|
||||
}
|
||||
} else if (payload && typeof (payload as Blob).text === "function") {
|
||||
const text = await (payload as Blob).text();
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
}
|
||||
|
||||
let ids: string[] | undefined = Array.isArray(
|
||||
(parsed as { errorFileIds?: unknown })?.errorFileIds,
|
||||
)
|
||||
? (parsed as { errorFileIds: string[] }).errorFileIds
|
||||
: undefined;
|
||||
|
||||
if (!ids && typeof parsed === "string") {
|
||||
const match = parsed.match(
|
||||
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
|
||||
);
|
||||
if (match && match.length > 0) ids = Array.from(new Set(match));
|
||||
}
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
markFileError(id);
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles password-related errors with status code checking
|
||||
* @param error - The error object from axios
|
||||
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
|
||||
* @param fallbackMessage - Message to show for other errors
|
||||
* @returns Error message string
|
||||
*/
|
||||
export const handlePasswordError = async (
|
||||
error: any,
|
||||
incorrectPasswordMessage: string,
|
||||
fallbackMessage: string,
|
||||
): Promise<string> => {
|
||||
const status = error?.response?.status;
|
||||
|
||||
// Handle specific error cases with user-friendly messages
|
||||
// Backend returns 400 with PdfPasswordException for incorrect/missing PDF passwords
|
||||
if (status === 500) {
|
||||
return incorrectPasswordMessage;
|
||||
}
|
||||
if (status === 400) {
|
||||
const data = error?.response?.data;
|
||||
// ProblemDetail JSON has type "/errors/pdf-password", blob needs parsing
|
||||
const isPasswordError = await (async () => {
|
||||
if (data instanceof Blob) {
|
||||
try {
|
||||
const text = await data.text();
|
||||
return text.includes("pdf-password") || text.includes("passworded");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const type = data?.type ?? "";
|
||||
return type.includes("pdf-password");
|
||||
})();
|
||||
if (isPasswordError) {
|
||||
return incorrectPasswordMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, try to extract the message
|
||||
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
|
||||
const errorWithNormalizedData = {
|
||||
...error,
|
||||
response: {
|
||||
...error?.response,
|
||||
data: normalizedData,
|
||||
},
|
||||
};
|
||||
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Note: This utility should be used with useToolResources for ZIP operations
|
||||
import { getFilenameFromHeaders } from "@app/utils/fileResponseUtils";
|
||||
|
||||
export type ResponseHandler = (
|
||||
blob: Blob,
|
||||
originalFiles: File[],
|
||||
) => Promise<File[]> | File[];
|
||||
|
||||
/**
|
||||
* Processes a blob response into File(s).
|
||||
* - If a tool-specific responseHandler is provided, it is used.
|
||||
* - If responseHeaders provided and contains Content-Disposition, uses that filename.
|
||||
* - Otherwise, create a single file using the filePrefix + original name.
|
||||
* - If filePrefix is empty, preserves the original filename.
|
||||
*/
|
||||
export async function processResponse(
|
||||
blob: Blob,
|
||||
originalFiles: File[],
|
||||
filePrefix?: string,
|
||||
responseHandler?: ResponseHandler,
|
||||
responseHeaders?: Record<string, any>,
|
||||
): Promise<File[]> {
|
||||
if (responseHandler) {
|
||||
const out = await responseHandler(blob, originalFiles);
|
||||
return Array.isArray(out) ? out : [out as unknown as File];
|
||||
}
|
||||
|
||||
// Check if we should use the backend-provided filename from headers
|
||||
// Only when responseHeaders are explicitly provided (indicating the operation requested this)
|
||||
if (responseHeaders) {
|
||||
const contentDisposition = responseHeaders["content-disposition"];
|
||||
const backendFilename = getFilenameFromHeaders(contentDisposition);
|
||||
if (backendFilename) {
|
||||
const type =
|
||||
blob.type ||
|
||||
responseHeaders["content-type"] ||
|
||||
"application/octet-stream";
|
||||
return [new File([blob], backendFilename, { type })];
|
||||
}
|
||||
// If preserveBackendFilename was requested but no Content-Disposition header found,
|
||||
// fall back to default behavior (this handles cases where backend doesn't set the header)
|
||||
}
|
||||
|
||||
// Default behavior: use filePrefix + original name
|
||||
const original = originalFiles[0]?.name ?? "result.pdf";
|
||||
// Only add prefix if it's not empty - this preserves original filenames for file history
|
||||
const name = filePrefix ? `${filePrefix}${original}` : original;
|
||||
const type = blob.type || "application/octet-stream";
|
||||
|
||||
// File was modified by tool processing - set lastModified to current time
|
||||
return [
|
||||
new File([blob], name, {
|
||||
type,
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { ToolRegistryEntry, ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
scoreMatch,
|
||||
minScoreForQuery,
|
||||
normalizeForSearch,
|
||||
} from "@app/utils/fuzzySearch";
|
||||
|
||||
export interface RankedToolItem {
|
||||
item: [ToolId, ToolRegistryEntry];
|
||||
matchedText?: string;
|
||||
}
|
||||
|
||||
export function filterToolRegistryByQuery(
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
query: string,
|
||||
): RankedToolItem[] {
|
||||
const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
|
||||
if (!query.trim()) {
|
||||
return entries.map(([id, tool]) => ({
|
||||
item: [id, tool] as [ToolId, ToolRegistryEntry],
|
||||
}));
|
||||
}
|
||||
|
||||
const nq = normalizeForSearch(query);
|
||||
const threshold = minScoreForQuery(query);
|
||||
|
||||
const exactName: Array<{ id: ToolId; tool: ToolRegistryEntry; pos: number }> =
|
||||
[];
|
||||
const exactSyn: Array<{
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
text: string;
|
||||
pos: number;
|
||||
}> = [];
|
||||
const fuzzyName: Array<{
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
score: number;
|
||||
text: string;
|
||||
}> = [];
|
||||
const fuzzySyn: Array<{
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
score: number;
|
||||
text: string;
|
||||
}> = [];
|
||||
|
||||
for (const [id, tool] of entries) {
|
||||
const nameNorm = normalizeForSearch(tool.name || "");
|
||||
const pos = nameNorm.indexOf(nq);
|
||||
if (pos !== -1) {
|
||||
exactName.push({ id, tool, pos });
|
||||
continue;
|
||||
}
|
||||
|
||||
const syns = Array.isArray(tool.synonyms) ? tool.synonyms : [];
|
||||
let matchedExactSyn: { text: string; pos: number } | null = null;
|
||||
for (const s of syns) {
|
||||
const sn = normalizeForSearch(s);
|
||||
const sp = sn.indexOf(nq);
|
||||
if (sp !== -1) {
|
||||
matchedExactSyn = { text: s, pos: sp };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedExactSyn) {
|
||||
exactSyn.push({
|
||||
id,
|
||||
tool,
|
||||
text: matchedExactSyn.text,
|
||||
pos: matchedExactSyn.pos,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuzzy name
|
||||
const nameScore = scoreMatch(query, tool.name || "");
|
||||
if (nameScore >= threshold) {
|
||||
fuzzyName.push({ id, tool, score: nameScore, text: tool.name || "" });
|
||||
}
|
||||
|
||||
// Fuzzy synonyms (we'll consider these only if fuzzy name results are weak)
|
||||
let bestSynScore = 0;
|
||||
let bestSynText = "";
|
||||
for (const s of syns) {
|
||||
const synScore = scoreMatch(query, s);
|
||||
if (synScore > bestSynScore) {
|
||||
bestSynScore = synScore;
|
||||
bestSynText = s;
|
||||
}
|
||||
if (bestSynScore >= 95) break;
|
||||
}
|
||||
if (bestSynScore >= threshold) {
|
||||
fuzzySyn.push({ id, tool, score: bestSynScore, text: bestSynText });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort within buckets
|
||||
exactName.sort(
|
||||
(a, b) =>
|
||||
a.pos - b.pos || (a.tool.name || "").length - (b.tool.name || "").length,
|
||||
);
|
||||
exactSyn.sort((a, b) => a.pos - b.pos || a.text.length - b.text.length);
|
||||
fuzzyName.sort((a, b) => b.score - a.score);
|
||||
fuzzySyn.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Concatenate buckets with de-duplication by tool id
|
||||
const seen = new Set<string>();
|
||||
const ordered: RankedToolItem[] = [];
|
||||
|
||||
const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ordered.push({ item: [id, tool], matchedText });
|
||||
};
|
||||
|
||||
for (const { id, tool } of exactName) push(id as ToolId, tool, tool.name);
|
||||
for (const { id, tool, text } of exactSyn) push(id as ToolId, tool, text);
|
||||
for (const { id, tool, text } of fuzzyName) push(id as ToolId, tool, text);
|
||||
for (const { id, tool, text } of fuzzySyn) push(id as ToolId, tool, text);
|
||||
|
||||
if (ordered.length > 0) return ordered;
|
||||
|
||||
// Fallback: return everything unchanged
|
||||
return entries.map(([id, tool]) => ({
|
||||
item: [id, tool] as [ToolId, ToolRegistryEntry],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
// Helper function to get synonyms for a tool (only from translations)
|
||||
export const getSynonyms = (t: TFunction, toolId: string): string[] => {
|
||||
try {
|
||||
const candidateKeys = [`home.${toolId}.tags`, `${toolId}.tags`];
|
||||
const match = candidateKeys
|
||||
.map((key) => ({ key, value: t(key) as unknown as string }))
|
||||
.find(({ key, value }) => value && value !== key);
|
||||
const tags = match?.value;
|
||||
const usedKey = match?.key;
|
||||
|
||||
if (!tags) {
|
||||
console.warn(`[Tags] Missing tags for tool: ${toolId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split by comma and clean up the tags
|
||||
const cleanedTags = tags
|
||||
.split(",")
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter((tag: string) => tag.length > 0);
|
||||
|
||||
// Log the tags found for this tool
|
||||
if (cleanedTags.length > 0) {
|
||||
console.info(
|
||||
`[Tags] Tool "${toolId}" (${usedKey}) has ${cleanedTags.length} tags:`,
|
||||
cleanedTags,
|
||||
);
|
||||
} else {
|
||||
console.warn(`[Tags] Tool "${toolId}" has empty tags value`);
|
||||
}
|
||||
|
||||
return cleanedTags;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Tags] Failed to get translated synonyms for tool ${toolId}:`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
// Map URL paths to tool keys (multiple URLs can map to same tool)
|
||||
export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
// Basic tools - standard patterns
|
||||
"/split": "split",
|
||||
"/split-pdfs": "split",
|
||||
"/merge": "merge",
|
||||
"/merge-pdfs": "merge",
|
||||
"/compress": "compress",
|
||||
"/compress-pdf": "compress",
|
||||
"/rotate": "rotate",
|
||||
"/rotate-pdf": "rotate",
|
||||
"/repair": "repair",
|
||||
"/flatten": "flatten",
|
||||
"/crop": "crop",
|
||||
|
||||
// Convert tool and all its variants
|
||||
"/convert": "convert",
|
||||
"/convert-pdf": "convert",
|
||||
"/file-to-pdf": "convert",
|
||||
"/eml-to-pdf": "convert",
|
||||
"/html-to-pdf": "convert",
|
||||
"/markdown-to-pdf": "convert",
|
||||
"/pdf-to-csv": "convert",
|
||||
"/pdf-to-xlsx": "convert",
|
||||
"/pdf-to-img": "convert",
|
||||
"/pdf-to-markdown": "convert",
|
||||
"/pdf-to-pdfa": "convert",
|
||||
"/pdf-to-word": "convert",
|
||||
"/pdf-to-xml": "convert",
|
||||
"/cbr-to-pdf": "convert",
|
||||
"/pdf-to-cbr": "convert",
|
||||
"/cbz-to-pdf": "convert",
|
||||
"/pdf-to-cbz": "convert",
|
||||
|
||||
// Security tools
|
||||
"/add-password": "addPassword",
|
||||
"/remove-password": "removePassword",
|
||||
"/change-permissions": "changePermissions",
|
||||
"/cert-sign": "certSign",
|
||||
"/manage-signatures": "certSign",
|
||||
"/remove-certificate-sign": "removeCertSign",
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-pdf-forms": "unlockPDFForms",
|
||||
"/validate-signature": "validateSignature",
|
||||
|
||||
// Content manipulation
|
||||
"/sanitize": "sanitize",
|
||||
"/sanitize-pdf": "sanitize",
|
||||
"/ocr": "ocr",
|
||||
"/ocr-pdf": "ocr",
|
||||
"/watermark": "watermark",
|
||||
"/add-watermark": "watermark",
|
||||
"/add-image": "addImage",
|
||||
"/add-stamp": "addStamp",
|
||||
"/add-page-numbers": "addPageNumbers",
|
||||
"/redact": "redact",
|
||||
|
||||
// Page manipulation
|
||||
"/remove-pages": "removePages",
|
||||
"/remove-blanks": "removeBlanks",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/single-large-page": "pdfToSinglePage",
|
||||
"/page-layout": "pageLayout",
|
||||
"/scale-pages": "scalePages",
|
||||
"/booklet-imposition": "bookletImposition",
|
||||
|
||||
// Splitting tools
|
||||
"/auto-split-pdf": "split",
|
||||
"/auto-size-split-pdf": "split",
|
||||
"/scanner-image-split": "scannerImageSplit",
|
||||
|
||||
// Annotation and content removal
|
||||
"/annotations": "annotate",
|
||||
"/annotate": "annotate",
|
||||
"/remove-annotations": "removeAnnotations",
|
||||
"/remove-image": "removeImage",
|
||||
|
||||
// Image and visual tools
|
||||
"/extract-images": "extractImages",
|
||||
"/adjust-contrast": "adjustContrast",
|
||||
"/fake-scan": "scannerEffect",
|
||||
"/replace-color-pdf": "replaceColor",
|
||||
|
||||
// Metadata and info
|
||||
"/change-metadata": "changeMetadata",
|
||||
"/get-pdf-info": "getPdfInfo",
|
||||
"/add-attachments": "addAttachments",
|
||||
|
||||
// Advanced tools
|
||||
"/overlay-pdfs": "overlayPdfs",
|
||||
"/edit-table-of-contents": "editTableOfContents",
|
||||
"/auto-rename": "autoRename",
|
||||
"/compare": "compare",
|
||||
"/multi-tool": "multiTool",
|
||||
"/show-js": "showJS",
|
||||
|
||||
// Special/utility tools
|
||||
"/read": "read",
|
||||
"/automate": "automate",
|
||||
"/sign": "sign",
|
||||
"/add-text": "addText",
|
||||
"/pdf-text-editor": "pdfTextEditor",
|
||||
|
||||
// Developer tools
|
||||
"/dev-api": "devApi",
|
||||
"/dev-folder-scanning": "devFolderScanning",
|
||||
"/dev-sso-guide": "devSsoGuide",
|
||||
"/dev-airgapped": "devAirgapped",
|
||||
|
||||
// Legacy URL mappings from sitemap
|
||||
"/pdf-organizer": "reorganizePages",
|
||||
"/multi-page-layout": "pageLayout",
|
||||
"/extract-page": "extractPages",
|
||||
"/pdf-to-single-page": "pdfToSinglePage",
|
||||
"/img-to-pdf": "convert",
|
||||
"/pdf-to-presentation": "convert",
|
||||
"/pdf-to-text": "convert",
|
||||
"/pdf-to-html": "convert",
|
||||
"/auto-redact": "redact",
|
||||
"/stamp": "addStamp",
|
||||
"/view-pdf": "read",
|
||||
"/get-info-on-pdf": "getPdfInfo",
|
||||
"/remove-image-pdf": "removeImage",
|
||||
"/replace-and-invert-color-pdf": "replaceColor",
|
||||
"/pipeline": "automate",
|
||||
"/extract-image-scans": "scannerImageSplit",
|
||||
"/show-javascript": "showJS",
|
||||
"/scanner-effect": "scannerEffect",
|
||||
"/split-by-size-or-count": "split",
|
||||
"/overlay-pdf": "overlayPdfs",
|
||||
"/split-pdf-by-sections": "split",
|
||||
"/split-pdf-by-chapters": "split",
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* URL routing utilities for tool navigation with registry support
|
||||
*/
|
||||
|
||||
import { ToolRoute } from "@app/types/navigation";
|
||||
import { ToolId, isValidToolId } from "@app/types/toolId";
|
||||
import { getDefaultWorkbench } from "@app/types/workbench";
|
||||
import {
|
||||
ToolRegistry,
|
||||
getToolWorkbench,
|
||||
getToolUrlPath,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { firePixel } from "@app/utils/scarfTracking";
|
||||
import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
|
||||
/**
|
||||
* Parse the current URL to extract tool routing information
|
||||
*/
|
||||
export function parseToolRoute(registry: ToolRegistry): ToolRoute {
|
||||
const fullPath = window.location.pathname;
|
||||
// Remove base path to get app-relative path
|
||||
const path =
|
||||
BASE_PATH && fullPath.startsWith(BASE_PATH)
|
||||
? fullPath.slice(BASE_PATH.length) || "/"
|
||||
: fullPath;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// First, check URL mapping for multiple URL aliases
|
||||
const mappedToolId = URL_TO_TOOL_MAP[path];
|
||||
if (mappedToolId && registry[mappedToolId]) {
|
||||
const tool = registry[mappedToolId];
|
||||
return {
|
||||
workbench: getToolWorkbench(tool),
|
||||
toolId: mappedToolId,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: Try to find tool by primary URL path in registry
|
||||
for (const [toolId, tool] of Object.entries(registry)) {
|
||||
const toolUrlPath = getToolUrlPath(toolId);
|
||||
if (path === toolUrlPath && isValidToolId(toolId)) {
|
||||
return {
|
||||
workbench: getToolWorkbench(tool),
|
||||
toolId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for query parameter fallback (e.g., ?tool=split)
|
||||
const toolParam = searchParams.get("tool");
|
||||
if (toolParam && isValidToolId(toolParam) && registry[toolParam]) {
|
||||
const tool = registry[toolParam];
|
||||
return {
|
||||
workbench: getToolWorkbench(tool),
|
||||
toolId: toolParam,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to fileEditor workbench for home page
|
||||
return {
|
||||
workbench: getDefaultWorkbench(),
|
||||
toolId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update URL and fire analytics pixel
|
||||
*/
|
||||
function updateUrl(
|
||||
newPath: string,
|
||||
searchParams: URLSearchParams,
|
||||
replace: boolean = false,
|
||||
): void {
|
||||
const currentPath = window.location.pathname;
|
||||
const queryString = searchParams.toString();
|
||||
const fullUrl = newPath + (queryString ? `?${queryString}` : "");
|
||||
|
||||
// Only update URL and fire pixel if something actually changed
|
||||
if (
|
||||
currentPath !== newPath ||
|
||||
window.location.search !== (queryString ? `?${queryString}` : "")
|
||||
) {
|
||||
if (replace) {
|
||||
window.history.replaceState(null, "", fullUrl);
|
||||
} else {
|
||||
window.history.pushState(null, "", fullUrl);
|
||||
}
|
||||
firePixel(newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the URL to reflect the current tool selection
|
||||
*/
|
||||
export function updateToolRoute(
|
||||
toolId: ToolId,
|
||||
registry: ToolRegistry,
|
||||
replace: boolean = false,
|
||||
): void {
|
||||
const tool = registry[toolId];
|
||||
if (!tool) {
|
||||
console.warn(`Tool ${toolId} not found in registry`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolPath = getToolUrlPath(toolId);
|
||||
const newPath = withBasePath(toolPath);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Remove tool query parameter since we're using path-based routing
|
||||
searchParams.delete("tool");
|
||||
|
||||
updateUrl(newPath, searchParams, replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tool routing and return to home page
|
||||
*/
|
||||
export function clearToolRoute(replace: boolean = false): void {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.delete("tool");
|
||||
|
||||
updateUrl(withBasePath("/"), searchParams, replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean tool name for display purposes using registry
|
||||
*/
|
||||
export function getToolDisplayName(
|
||||
toolId: ToolId,
|
||||
registry: ToolRegistry,
|
||||
): string {
|
||||
const tool = registry[toolId];
|
||||
return tool ? tool.name : toolId;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const DEFAULT_VISIBILITY_THRESHOLD = 70; // Require at least 70% of the page height to be visible
|
||||
export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present
|
||||
|
||||
export interface ZoomViewport {
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type AutoZoomDecision =
|
||||
| { type: "fallback"; zoom: number }
|
||||
| { type: "fitWidth" }
|
||||
| { type: "adjust"; zoom: number };
|
||||
|
||||
export interface AutoZoomParams {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
fitWidthZoom: number;
|
||||
pagesPerSpread: number;
|
||||
pageRect?: { width: number; height: number } | null;
|
||||
metadataAspectRatio?: number | null;
|
||||
visibilityThreshold?: number;
|
||||
fallbackZoom?: number;
|
||||
}
|
||||
|
||||
export function determineAutoZoom({
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
fitWidthZoom,
|
||||
pagesPerSpread,
|
||||
pageRect,
|
||||
metadataAspectRatio,
|
||||
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD,
|
||||
fallbackZoom = DEFAULT_FALLBACK_ZOOM,
|
||||
}: AutoZoomParams): AutoZoomDecision {
|
||||
// Get aspect ratio from pageRect or metadata
|
||||
const rectWidth = pageRect?.width ?? 0;
|
||||
const rectHeight = pageRect?.height ?? 0;
|
||||
const aspectRatio: number | null =
|
||||
rectWidth > 0 ? rectHeight / rectWidth : (metadataAspectRatio ?? null);
|
||||
|
||||
// Need aspect ratio to proceed
|
||||
if (!aspectRatio || aspectRatio <= 0) {
|
||||
return { type: "fallback", zoom: Math.min(fitWidthZoom, fallbackZoom) };
|
||||
}
|
||||
|
||||
// Landscape pages need 100% visibility, portrait need the specified threshold
|
||||
const isLandscape = aspectRatio < 1;
|
||||
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
|
||||
|
||||
// Calculate zoom level that shows targetVisibility% of page height
|
||||
const pageHeightAtFitWidth = (viewportWidth / pagesPerSpread) * aspectRatio;
|
||||
const heightBasedZoom =
|
||||
(fitWidthZoom * (viewportHeight / pageHeightAtFitWidth)) /
|
||||
(targetVisibility / 100);
|
||||
|
||||
// Use whichever zoom is smaller (more zoomed out) to satisfy both width and height constraints
|
||||
if (heightBasedZoom < fitWidthZoom) {
|
||||
// Need to zoom out from fitWidth to show enough height
|
||||
return { type: "adjust", zoom: heightBasedZoom };
|
||||
} else {
|
||||
// fitWidth already shows enough
|
||||
return { type: "fitWidth" };
|
||||
}
|
||||
}
|
||||
|
||||
export interface MeasurePageRectOptions {
|
||||
selector?: string;
|
||||
maxAttempts?: number;
|
||||
shouldCancel?: () => boolean;
|
||||
}
|
||||
|
||||
export async function measureRenderedPageRect({
|
||||
selector = '[data-page-index="0"]',
|
||||
maxAttempts = 12,
|
||||
shouldCancel,
|
||||
}: MeasurePageRectOptions = {}): Promise<DOMRect | null> {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const waitForNextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (shouldCancel?.()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = document.querySelector(selector) as HTMLElement | null;
|
||||
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
await waitForNextFrame();
|
||||
}
|
||||
} finally {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FitWidthResizeOptions {
|
||||
isManaged: boolean;
|
||||
requestFitWidth: () => void;
|
||||
onDebouncedResize: () => void;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export function useFitWidthResize({
|
||||
isManaged,
|
||||
requestFitWidth,
|
||||
onDebouncedResize,
|
||||
debounceMs = 150,
|
||||
}: FitWidthResizeOptions): void {
|
||||
const managedRef = useRef(isManaged);
|
||||
const requestFitWidthRef = useRef(requestFitWidth);
|
||||
const onDebouncedResizeRef = useRef(onDebouncedResize);
|
||||
|
||||
useEffect(() => {
|
||||
managedRef.current = isManaged;
|
||||
}, [isManaged]);
|
||||
|
||||
useEffect(() => {
|
||||
requestFitWidthRef.current = requestFitWidth;
|
||||
}, [requestFitWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
onDebouncedResizeRef.current = onDebouncedResize;
|
||||
}, [onDebouncedResize]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const handleResize = () => {
|
||||
if (!managedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
requestFitWidthRef.current?.();
|
||||
onDebouncedResizeRef.current?.();
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [debounceMs]);
|
||||
}
|
||||
Reference in New Issue
Block a user