From ba2588ea2419ec9bf8c38f60435945f95e5d4de7 Mon Sep 17 00:00:00 2001 From: Felix Kaspar Date: Mon, 27 Nov 2023 23:35:18 +0100 Subject: [PATCH] WIP joi validation for impose --- .../src/routes/api/workflow-controller.ts | 5 +- .../src/dynamic-ui/OperatorConstraints.ts | 6 +- shared-operations/src/functions/impose.ts | 176 +++++++++++------- shared-operations/src/functions/index.ts | 52 +++--- shared-operations/src/functions/scalePage.ts | 4 +- shared-operations/src/wrappers/PdfFile.ts | 12 +- shared-operations/src/wrappers/PdfFileJoi.ts | 14 ++ 7 files changed, 154 insertions(+), 115 deletions(-) create mode 100644 shared-operations/src/wrappers/PdfFileJoi.ts diff --git a/server-node/src/routes/api/workflow-controller.ts b/server-node/src/routes/api/workflow-controller.ts index aad76c5e5..8b1951449 100644 --- a/server-node/src/routes/api/workflow-controller.ts +++ b/server-node/src/routes/api/workflow-controller.ts @@ -42,10 +42,7 @@ router.post("/:workflowUuid?", [ } } - // TODO: Replace with static multer function of pdffile - const inputs = await Promise.all((req.files as Express.Multer.File[]).map(async file => { - return new PdfFile(file.originalname.replace(/\.[^/.]+$/, ""), new Uint8Array(await file.buffer), RepresentationType.Uint8Array, file.originalname.replace(/\.[^/.]+$/, "")); - })); + const inputs = PdfFile.fromMulterFiles(req.files as Express.Multer.File[]); // Allow option to do it synchronously and just make a long request if(req.body.async === "false") { diff --git a/shared-operations/src/dynamic-ui/OperatorConstraints.ts b/shared-operations/src/dynamic-ui/OperatorConstraints.ts index 363716aca..30b539afb 100644 --- a/shared-operations/src/dynamic-ui/OperatorConstraints.ts +++ b/shared-operations/src/dynamic-ui/OperatorConstraints.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; -import { PdfFileSchema } from '../wrappers/PdfFile'; +import { JoiPdfFileSchema } from '../wrappers/PdfFile'; export class RecordConstraint { @@ -55,10 +55,10 @@ export class FieldConstraint { } else if (typeof this.type == 'string') { switch (this.type) { case "file.pdf": - schema = PdfFileSchema; + schema = JoiPdfFileSchema; break; case "files.pdf": - schema = Joi.array().items(PdfFileSchema); + schema = Joi.array().items(JoiPdfFileSchema); break; case "string": schema = Joi.string(); diff --git a/shared-operations/src/functions/impose.ts b/shared-operations/src/functions/impose.ts index a92536ddf..19910776c 100644 --- a/shared-operations/src/functions/impose.ts +++ b/shared-operations/src/functions/impose.ts @@ -1,27 +1,117 @@ import { PdfFile, RepresentationType } from "../wrappers/PdfFile"; import { FieldConstraint, RecordConstraint } from '../dynamic-ui/OperatorConstraints' -import { IOType, Operator, Progress } from "."; +import { Operator, Progress, oneToOne } from "."; import * as pdfcpuWrapper from "#pdfcpu"; // This is updated by tsconfig.json/paths for the context (browser, node, etc.) this module is used in. -export type ImposeParamsType = { - file: PdfFile; - /** Accepted values are 2, 3, 4, 8, 9, 12, 16 - see: {@link https://pdfcpu.io/generate/nup.html#n-up-value} */ - nup: 2 | 3 | 4 | 8 | 9 | 12 | 16; - /** A0-A10, other formats available - see: {@link https://pdfcpu.io/paper.html} */ - format: string; -} +import Joi from "joi"; +import { JoiPDFFileSchema } from "../wrappers/PdfFileJoi"; + + +// TODO: This will be replaced by a real translator +const translationObject = { + operators: { + nup: { + friendlyName: "PDF-Imposition / PDF-N-Up", + description: "Put multiple pages of the input document into a single page of the output document.", + values: { + nup: { + friendlyName: "Page Format", + description: "The Page Size of the ouput document. Append L or P to force Landscape or Portrait." + }, + format: { + friendlyName: "N-Up-Value", + description: "How many pages should be in one output page" + } + } + } + }, + inputs: { + pdfFile: { + name: "PDF-File(s)", + description: "This operator takes a PDF-File(s) as input" + } + }, + outputs: { + pdfFile: { + name: "PDF-File(s)", + description: "This operator outputs PDF-File(s)" + } + } +} export class Impose extends Operator { static type: string = "impose"; - static mayInput: IOType = IOType.PDF; - static willOutput: IOType = IOType.PDF; + /** + * Validation + */ + + static inputSchema = JoiPDFFileSchema.label(translationObject.inputs.pdfFile.name).description(translationObject.inputs.pdfFile.description); + static valueSchema = Joi.object({ + nup: Joi.number().integer().valid(2, 3, 4, 8, 9, 12, 16).required() + .label(translationObject.operators.nup.values.nup.friendlyName).description(translationObject.operators.nup.values.nup.description) + .example("3").example("4"), + format: Joi.string().valid(...[ + // ISO 216:1975 A + "4A0", "2A0", "A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", + + // ISO 216:1975 B + "B0+", "B0", "B1+", "B1", "B2+", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9", "B10", + + // ISO 269:1985 C + "C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10", + + // ISO 217:2013 untrimmed + "RA0", "RA1", "RA2", "RA3", "RA4", "SRA0", "SRA1", "SRA2", "SRA3", "SRA4", "SRA1+", "SRA2+", "SRA3+", "SRA3++", + + // American + "SuperB", "Tabloid", "Legal", "GovLegal", "Letter", "GovLetter", "Executive", "HalfLetter", "JuniorLegal", "Photo", + + // ANSI/ASME Y14.1 + "ANSIA", "ANSIB", "ANSIC", "ANSID", "ANSIE", "ANSIF", + + // ANSI/ASME Y14.1 Architectural series + "ARCHA", "ARCHB", "ARCHC", "ARCHD", "ARCHE", "ARCHE1", "ARCHE2", "ARCHE3", + + // American uncut + "Bond", "Book", "Cover", "Index", "NewsPrint", "Offset", + + // English uncut + "Crown", "DoubleCrown", "Quad", "Demy", "DoubleDemy", "Medium", "Royal", "SuperRoyal", + "DoublePott", "DoublePost", "Foolscap", "DoubleFoolscap", + + // F4 + + // China GB/T 148-1997 D Series + "D0", "D1", "D2", "D3", "D4", "D5", "D6", + "RD0", "RD1", "RD2", "RD3", "RD4", "RD5", "RD6", + + // Japan + "JIS-B0", "JIS-B1", "JIS-B2", "JIS-B3", "JIS-B4", "JIS-B5", "JIS-B6", + "JIS-B7", "JIS-B8", "JIS-B9", "JIS-B10", "JIS-B11", "JIS-B12", + "Shirokuban4", "Shirokuban5", "Shirokuban6", "Kiku4", "Kiku5", "AB", "B40", "Shikisen" + ].flatMap(size => [size, size + "P", size + "L"])) + .required() + .label(translationObject.operators.nup.values.format.friendlyName).description(translationObject.operators.nup.values.format.description) + .example("A4").example("A3L") + }); + static outputSchema = JoiPDFFileSchema.label(translationObject.outputs.pdfFile.name).description(translationObject.outputs.pdfFile.description); + + static schema = Joi.object({ + input: Impose.inputSchema.required(), + values: Impose.valueSchema.required(), + output: Impose.outputSchema.optional() + }).label(translationObject.operators.nup.friendlyName).description(translationObject.operators.nup.description); + + /** + * Logic + */ /** PDF-Imposition, PDF-N-Up: Put multiple pages of the input document into a single page of the output document. - see: {@link https://en.wikipedia.org/wiki/N-up} */ async run(input: PdfFile[], progressCallback: (state: Progress) => void): Promise { - return this.oneToOne(input, async (input, index, max) => { + return oneToOne(input, async (input, index, max) => { //TODO: Support custom Page Sizes // https://pdfcpu.io/generate/nup.html const uint8Array = await pdfcpuWrapper.oneToOne( @@ -57,67 +147,13 @@ export class Impose extends Operator { if(!baseValidationResults.valid) return baseValidationResults; + // TODO: Fully integrate joi in the base and remove this func - // TODO: This should be ported to SaudF's RecordValidator - if(!this.actionValues.nup) { - return { valid: false, reason: "nup is not defined" } - } - if(!(ImposeParamConstraints.record["nup"].type as number[]).includes(parseInt(this.actionValues.nup))) { - return { valid: false, reason: "NUp accepted values are 2, 3, 4, 8, 9, 12, 16 - see: https://pdfcpu.io/generate/nup.html#n-up-value"} - } + this.actionValues = Impose.valueSchema.validate(this.actionValues); - if(!this.actionValues.format) { - return { valid: false, reason: "format is not defined" } - } - if(!(ImposeParamConstraints.record["format"].type as string[]).includes(this.actionValues.format)) { - return { valid: false, reason: "invalid fromat provided - see: https://pdfcpu.io/paper.html"} - } + if(this.actionValues.error) + return { valid: false, reason: this.actionValues } return { valid: true } } -} - -export const ImposeParamConstraints = new RecordConstraint({ - file: new FieldConstraint("display.key", "file.pdf", true, "hint.key"), - nup: new FieldConstraint("display.key", [2, 3, 4, 8, 9, 12, 16], true, "hint.key"), - format: new FieldConstraint("display.key", [ - // ISO 216:1975 A - "4A0", "2A0", "A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", - - // ISO 216:1975 B - "B0+", "B0", "B1+", "B1", "B2+", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9", "B10", - - // ISO 269:1985 C - "C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10", - - // ISO 217:2013 untrimmed - "RA0", "RA1", "RA2", "RA3", "RA4", "SRA0", "SRA1", "SRA2", "SRA3", "SRA4", "SRA1+", "SRA2+", "SRA3+", "SRA3++", - - // American - "SuperB", "Tabloid", "Legal", "GovLegal", "Letter", "GovLetter", "Executive", "HalfLetter", "JuniorLegal", "Photo", - - // ANSI/ASME Y14.1 - "ANSIA", "ANSIB", "ANSIC", "ANSID", "ANSIE", "ANSIF", - - // ANSI/ASME Y14.1 Architectural series - "ARCHA", "ARCHB", "ARCHC", "ARCHD", "ARCHE", "ARCHE1", "ARCHE2", "ARCHE3", - - // American uncut - "Bond", "Book", "Cover", "Index", "NewsPrint", "Offset", - - // English uncut - "Crown", "DoubleCrown", "Quad", "Demy", "DoubleDemy", "Medium", "Royal", "SuperRoyal", - "DoublePott", "DoublePost", "Foolscap", "DoubleFoolscap", - - // F4 - - // China GB/T 148-1997 D Series - "D0", "D1", "D2", "D3", "D4", "D5", "D6", - "RD0", "RD1", "RD2", "RD3", "RD4", "RD5", "RD6", - - // Japan - "JIS-B0", "JIS-B1", "JIS-B2", "JIS-B3", "JIS-B4", "JIS-B5", "JIS-B6", - "JIS-B7", "JIS-B8", "JIS-B9", "JIS-B10", "JIS-B11", "JIS-B12", - "Shirokuban4", "Shirokuban5", "Shirokuban6", "Kiku4", "Kiku5", "AB", "B40", "Shikisen" - ].flatMap(size => [size, size + "P", size + "L"]), true, "hint.key"), -}) \ No newline at end of file +} \ No newline at end of file diff --git a/shared-operations/src/functions/index.ts b/shared-operations/src/functions/index.ts index 480f64dd5..07022e6c9 100644 --- a/shared-operations/src/functions/index.ts +++ b/shared-operations/src/functions/index.ts @@ -1,8 +1,5 @@ import { Action } from "../../declarations/Action"; - -export enum IOType { - PDF, Image, Text // TODO: Extend with Document File Types -} +import Joi from "joi"; export interface ValidationResult { valid: boolean, @@ -17,12 +14,14 @@ export interface Progress { } export class Operator { - /** The type of the operator in camelCase (impose, merge, etc.) */ + /** The internal name of the operator in camelCase (impose, merge, etc.) */ static type: string; - // This will most likely be needed in the node Editor - static mayInput: IOType; - static willOutput: IOType; + /** The Joi validators & decorators */ + static inputSchema: Joi.Schema; + static valueSchema: Joi.Schema; + static outputSchema: Joi.Schema; + static schema: Joi.Schema; actionValues: any; @@ -40,26 +39,25 @@ export class Operator { } return { valid: true }; } +} - /** This function should be used if the Operation may take multiple files as inputs and only outputs one file */ - protected async nToOne (inputs: I[], callback: (input: I[]) => Promise): Promise { - return [await callback(inputs)]; +/** This function should be used if the Operation may take multiple files as inputs and only outputs one file */ +export async function nToOne (inputs: I[], callback: (input: I[]) => Promise): Promise { + return [await callback(inputs)]; +} + +/** This function should be used if the Operation takes one file as input and may output multiple files */ +export async function oneToN (inputs: I[], callback: (input: I, index: number, max: number) => Promise): Promise { + let output: O[] = [] + for (let i = 0; i < inputs.length; i++) { + output = output.concat(await callback(inputs[i], i, inputs.length)); } + return output; +} - /** This function should be used if the Operation takes one file as input and may output multiple files */ - protected async oneToN (inputs: I[], callback: (input: I, index: number, max: number) => Promise): Promise { - let output: O[] = [] - for (let i = 0; i < inputs.length; i++) { - output = output.concat(await callback(inputs[i], i, inputs.length)); - } - - return output; - } - - /** This function should be used if the Operation takes one file as input and outputs only one file */ - protected async oneToOne (inputs: I[], callback: (input: I, index: number, max: number) => Promise): Promise { - return this.oneToN(inputs, async (input, index, max) => { - return [await callback(input, index, max)] - }); - } +/** This function should be used if the Operation takes one file as input and outputs only one file */ +export async function oneToOne (inputs: I[], callback: (input: I, index: number, max: number) => Promise): Promise { + return oneToN(inputs, async (input, index, max) => { + return [await callback(input, index, max)] + }); } \ No newline at end of file diff --git a/shared-operations/src/functions/scalePage.ts b/shared-operations/src/functions/scalePage.ts index 3395c874c..03925edd5 100644 --- a/shared-operations/src/functions/scalePage.ts +++ b/shared-operations/src/functions/scalePage.ts @@ -1,7 +1,7 @@ import Joi from 'joi'; import { PDFPage } from 'pdf-lib'; -import { PdfFile, RepresentationType, PdfFileSchema } from '../wrappers/PdfFile'; +import { PdfFile, RepresentationType, JoiPdfFileSchema } from '../wrappers/PdfFile'; const whSchema = Joi.string().custom((value, helpers) => { console.log("value.pageSize", typeof value) @@ -23,7 +23,7 @@ const whSchema = Joi.string().custom((value, helpers) => { }); export const ScalePageSchema = Joi.object({ - file: PdfFileSchema.required(), + file: JoiPdfFileSchema.required(), pageSize: Joi.alternatives().try(whSchema, Joi.array().items(whSchema)).required(), }); diff --git a/shared-operations/src/wrappers/PdfFile.ts b/shared-operations/src/wrappers/PdfFile.ts index ae31b342b..da5cef77a 100644 --- a/shared-operations/src/wrappers/PdfFile.ts +++ b/shared-operations/src/wrappers/PdfFile.ts @@ -91,7 +91,7 @@ export class PdfFile { this.filename = filename ? filename : originalFilename; if (this.filename.toLowerCase().endsWith(".pdf")) - this.filename = this.filename.slice(0, -4); + this.filename = this.filename.slice(0, -4); this.representation = representation; this.representationType = representationType; @@ -99,6 +99,7 @@ export class PdfFile { static fromMulterFile(value: Express.Multer.File): PdfFile { return new PdfFile(value.originalname, value.buffer as Uint8Array, RepresentationType.Uint8Array); + } static fromMulterFiles(values: Express.Multer.File[]): PdfFile[] { return values.map(v => PdfFile.fromMulterFile(v)); @@ -128,11 +129,4 @@ export class PdfFile { })); return docCache; } -} - -export const PdfFileSchema = Joi.any().custom((value) => { - if (!(value instanceof PdfFile)) { - throw new Error('value is not a PdfFile'); - } - return value; -}, "PdfFile validation"); +} \ No newline at end of file diff --git a/shared-operations/src/wrappers/PdfFileJoi.ts b/shared-operations/src/wrappers/PdfFileJoi.ts new file mode 100644 index 000000000..1fd9ef16f --- /dev/null +++ b/shared-operations/src/wrappers/PdfFileJoi.ts @@ -0,0 +1,14 @@ +import Joi from "joi"; +import { PdfFile } from "./PdfFile"; + +export const JoiPDFFileSchema = Joi.binary().custom((value: Express.Multer.File[] | PdfFile, helpers) => { + if (!(value instanceof PdfFile)) { + try { + return PdfFile.fromMulterFiles(value); + } catch (error) { + console.error(error); + throw new Error('value is not of type PdfFile'); + } + } + return value; +}, "pdffile validation"); \ No newline at end of file