From 952952c9053bef8b365c13dd329b83dc177439dc Mon Sep 17 00:00:00 2001 From: calixteman Date: Fri, 27 Mar 2026 18:35:17 +0100 Subject: [PATCH] [api-minor] Rewrite the ps lexer & parser and add a small Wasm compiler The main goal is to remove the eval-based interpreter. In order to have some good performances, the new parser performs some optimizations on the AST (similar to the ones in the previous implementation), and the Wasm compiler generates code for the optimized AST. For now, in case of errors or unsupported features, the Wasm compiler returns null and the old interpreter is used as a fallback. Few things are still missing: - a wasm-based interpreter using a stack (in case the ps code isn't stack-free); - a better js implementation in case of disabled wasm. but they will be added in follow-up patches. --- src/core/function.js | 30 + src/core/pdf_manager.js | 2 + src/core/postscript/ast.js | 1307 ++++++++++++++++++ src/core/postscript/lexer.js | 225 ++++ src/core/postscript/wasm_compiler.js | 1065 +++++++++++++++ test/unit/clitests.json | 1 + test/unit/jasmine-boot.js | 1 + test/unit/postscript_spec.js | 1847 ++++++++++++++++++++++++++ 8 files changed, 4478 insertions(+) create mode 100644 src/core/postscript/ast.js create mode 100644 src/core/postscript/lexer.js create mode 100644 src/core/postscript/wasm_compiler.js create mode 100644 test/unit/postscript_spec.js diff --git a/src/core/function.js b/src/core/function.js index 6f03e9997..b00e60670 100644 --- a/src/core/function.js +++ b/src/core/function.js @@ -21,18 +21,30 @@ import { MathClamp, shadow, unreachable, + warn, } from "../shared/util.js"; import { PostScriptLexer, PostScriptParser } from "./ps_parser.js"; import { BaseStream } from "./base_stream.js"; +import { buildPostScriptWasmFunction } from "./postscript/wasm_compiler.js"; import { isNumberArray } from "./core_utils.js"; import { LocalFunctionCache } from "./image_utils.js"; class PDFFunctionFactory { + static #useWasm = true; + + static setOptions({ useWasm }) { + PDFFunctionFactory.#useWasm = useWasm; + } + constructor({ xref, isEvalSupported = true }) { this.xref = xref; this.isEvalSupported = isEvalSupported !== false; } + get useWasm() { + return PDFFunctionFactory.#useWasm; + } + create(fn, parseArray = false) { let fnRef, parsedFn; @@ -358,6 +370,24 @@ class PDFFunction { throw new FormatError("No range."); } + if (factory.useWasm) { + try { + const wasmFn = buildPostScriptWasmFunction( + fn.getString(), + domain, + range + ); + if (wasmFn) { + return wasmFn; // (src, srcOffset, dest, destOffset) → void + } + } catch { + // Fall through to the existing interpreter-based path. + } + } + + warn("Unable to compile PS function, using interpreter"); + fn.reset(); + const lexer = new PostScriptLexer(fn); const parser = new PostScriptParser(lexer); const code = parser.parse(); diff --git a/src/core/pdf_manager.js b/src/core/pdf_manager.js index 82938dc0c..dfb8c4432 100644 --- a/src/core/pdf_manager.js +++ b/src/core/pdf_manager.js @@ -28,6 +28,7 @@ import { JpxImage } from "./jpx.js"; import { MissingDataException } from "./core_utils.js"; import { OperatorList } from "./operator_list.js"; import { PDFDocument } from "./document.js"; +import { PDFFunctionFactory } from "./function.js"; import { Stream } from "./stream.js"; function parseDocBaseUrl(url) { @@ -97,6 +98,7 @@ class BasePdfManager { IccColorSpace.setOptions(options); CmykICCBasedCS.setOptions(options); JBig2CCITTFaxWasmImage.setOptions(options); + PDFFunctionFactory.setOptions(options); } get docId() { diff --git a/src/core/postscript/ast.js b/src/core/postscript/ast.js new file mode 100644 index 000000000..c4c36dcf1 --- /dev/null +++ b/src/core/postscript/ast.js @@ -0,0 +1,1307 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormatError, warn } from "../../shared/util.js"; +import { Lexer, TOKEN } from "./lexer.js"; + +// Value types for tree nodes — used to select the correct code-generation +// path for type-sensitive operators (currently `not`). +const PS_VALUE_TYPE = { + numeric: 0, // known to be a number (f64 in Wasm) + boolean: 1, // known to be a boolean (0.0 = false, 1.0 = true in f64) + unknown: 2, // indeterminate at compile time +}; + +// AST node type constants + +const PS_NODE = { + // Parser AST node types (produced by Parser / parsePostScriptFunction) + program: 0, + block: 1, + number: 2, + operator: 3, + if: 4, + ifelse: 5, + // Tree AST node types (produced by PSStackToTree) + arg: 6, + const: 7, + unary: 8, + binary: 9, + ternary: 10, +}; + +// AST node classes + +class PsNode { + constructor(type) { + this.type = type; + } +} + +/** + * The root node. Wraps the outermost `{ … }` of a Type 4 function body. + */ +class PsProgram extends PsNode { + constructor(body) { + super(PS_NODE.program); + /** @type {PsBlock} */ + this.body = body; + } +} + +class PsBlock extends PsNode { + constructor(instructions) { + super(PS_NODE.block); + /** @type {Array} */ + this.instructions = instructions; + } +} + +class PsNumber extends PsNode { + /** @param {number} value */ + constructor(value) { + super(PS_NODE.number); + this.value = value; + } +} + +/** A regular PS operator (not `if` / `ifelse`). */ +class PsOperator extends PsNode { + /** @param {number} op — one of the TOKEN.* constants from lexer.js */ + constructor(op) { + super(PS_NODE.operator); + this.op = op; + } +} + +/** + * ` { thenBlock } if` + * + * The condition value is consumed from the operand stack at runtime. + */ +class PsIf extends PsNode { + /** @param {PsBlock} then */ + constructor(then) { + super(PS_NODE.if); + this.then = then; + } +} + +/** + * ` { thenBlock } { elseBlock } ifelse` + * + * The condition value is consumed from the operand stack at runtime. + */ +class PsIfElse extends PsNode { + /** + * @param {PsBlock} then + * @param {PsBlock} otherwise + */ + constructor(then, otherwise) { + super(PS_NODE.ifelse); + this.then = then; + this.otherwise = otherwise; + } +} + +// Tree AST node classes (produced by PSStackToTree) + +/** + * A function input argument. `index` is the zero-based position in the + * domain — in0 has index 0, in1 has index 1, etc. + */ +class PsArgNode extends PsNode { + /** @param {number} index */ + constructor(index) { + super(PS_NODE.arg); + this.index = index; + this.valueType = PS_VALUE_TYPE.numeric; + } +} + +/** + * A folded constant — a numeric or boolean literal that is known at + * compile time. + */ +class PsConstNode extends PsNode { + /** @param {number|boolean} value */ + constructor(value) { + super(PS_NODE.const); + this.value = value; + this.valueType = + typeof value === "boolean" + ? PS_VALUE_TYPE.boolean + : PS_VALUE_TYPE.numeric; + } +} + +/** + * A unary operation. + */ +class PsUnaryNode extends PsNode { + /** + * @param {number} op — TOKEN.* constant + * @param {PsNode} operand + * @param {number} [valueType] + */ + constructor(op, operand, valueType = PS_VALUE_TYPE.unknown) { + super(PS_NODE.unary); + this.op = op; + this.operand = operand; + this.valueType = valueType; + } +} + +/** + * A binary operation. + * + * `first` was the top-of-stack operand (popped first); + * `second` was the operand just below it (popped second). + * + * For non-commutative operators the mathematical meaning is + * second OP first + * e.g. `a b sub` → second = a, first = b → a − b. + */ +class PsBinaryNode extends PsNode { + /** + * @param {number} op — TOKEN.* constant + * @param {PsNode} first — was on top of stack + * @param {PsNode} second — was below top + * @param {number} [valueType] + */ + constructor(op, first, second, valueType = PS_VALUE_TYPE.unknown) { + super(PS_NODE.binary); + this.op = op; + this.first = first; + this.second = second; + this.valueType = valueType; + } +} + +/** + * A conditional expression: `cond ? then : otherwise`. + * + * Represents both PostScript `if` (where `otherwise` is the pre-existing + * stack value that would remain unchanged when the condition is false) and + * `ifelse` constructs, after the stack-to-tree conversion. + */ +class PsTernaryNode extends PsNode { + /** + * @param {PsNode} cond + * @param {PsNode} then + * @param {PsNode} otherwise + * @param {number} [valueType] + */ + constructor(cond, then, otherwise, valueType = PS_VALUE_TYPE.unknown) { + super(PS_NODE.ternary); + this.cond = cond; + this.then = then; + this.otherwise = otherwise; + this.valueType = valueType; + } +} + +class Parser { + constructor(lexer) { + this.lexer = lexer; + this._token = null; + } + + static _isRegularOperator(id) { + return id >= TOKEN.true && id < TOKEN.if; + } + + // Fetch the next token from the lexer. + _advance() { + this._token = this.lexer.next(); + } + + // Assert that the current token has the given id, consume it, and return it. + _expect(id) { + if (this._token.id !== id) { + throw new FormatError( + `PostScript function: expected token id ${id}, got ${this._token.id}.` + ); + } + const tok = this._token; + this._advance(); + return tok; + } + + /** + * Parse the full Type 4 function body. + * + * Grammar (simplified): + * program ::= '{' block '}' + * block ::= instruction* + * instruction ::= number + * | operator (any PS_OPERATOR except if / ifelse) + * | '{' block '}' 'if' + * | '{' block '}' '{' block '}' 'ifelse' + * + * @returns {PsProgram} + */ + parse() { + this._advance(); + this._expect(TOKEN.lbrace); + const block = this._parseBlock(); + this._expect(TOKEN.rbrace); + if (this._token.id !== TOKEN.eof) { + warn("PostScript function: unexpected content after closing brace."); + } + return new PsProgram(block); + } + + _parseBlock() { + const instructions = []; + + while (true) { + const tok = this._token; + switch (tok.id) { + case TOKEN.number: + instructions.push(new PsNumber(tok.value)); + this._advance(); + break; + + case TOKEN.lbrace: { + // Start of a sub-procedure: must be followed by 'if' or '{ } ifelse'. + this._advance(); + const thenBlock = this._parseBlock(); + this._expect(TOKEN.rbrace); + + if (this._token.id === TOKEN.if) { + this._advance(); + instructions.push(new PsIf(thenBlock)); + } else if (this._token.id === TOKEN.lbrace) { + this._advance(); + const elseBlock = this._parseBlock(); + this._expect(TOKEN.rbrace); + this._expect(TOKEN.ifelse); + instructions.push(new PsIfElse(thenBlock, elseBlock)); + } else { + throw new FormatError( + "PostScript function: a procedure block must be followed by 'if' or '{…} ifelse'." + ); + } + break; + } + + case TOKEN.rbrace: + case TOKEN.eof: + // End of this block; let the caller consume the '}'. + return new PsBlock(instructions); + + case TOKEN.if: + case TOKEN.ifelse: + // 'if'/'ifelse' without a preceding block. + throw new FormatError( + `PostScript function: unexpected '${tok.value}' operator.` + ); + + default: + if (Parser._isRegularOperator(tok.id)) { + instructions.push(new PsOperator(tok.id)); + this._advance(); + break; + } + throw new FormatError( + `PostScript function: unexpected token id ${tok.id}.` + ); + } + } + } +} + +/** + * Convenience function: tokenize and parse a PostScript Type 4 function body + * given as a plain string (already decoded from the PDF stream). + * + * @param {string} source + * @returns {PsProgram} + */ +function parsePostScriptFunction(source) { + return new Parser(new Lexer(source)).parse(); +} + +// Stack-to-tree transformation + +/** + * Structural equality for tree nodes. + * Returns true when `a` and `b` represent the same sub-expression. + * Reference equality (`a === b`) is checked first, so shared nodes + * produced by `dup` are handled in O(1). + */ +function _nodesEqual(a, b) { + if (a === b) { + return true; + } + if (a.type !== b.type) { + return false; + } + switch (a.type) { + case PS_NODE.arg: + return a.index === b.index; + case PS_NODE.const: + return a.value === b.value; + case PS_NODE.unary: + return a.op === b.op && _nodesEqual(a.operand, b.operand); + case PS_NODE.binary: + return ( + a.op === b.op && + _nodesEqual(a.first, b.first) && + _nodesEqual(a.second, b.second) + ); + case PS_NODE.ternary: + return ( + _nodesEqual(a.cond, b.cond) && + _nodesEqual(a.then, b.then) && + _nodesEqual(a.otherwise, b.otherwise) + ); + default: + return false; + } +} + +/** + * Evaluate a binary PostScript operator on two compile-time-known values. + * `a` is the second operand (was below top); `b` is the first (was on top). + * Returns `undefined` when the operation cannot be safely folded. + */ +function _evalBinaryConst(op, a, b) { + switch (op) { + case TOKEN.add: + return a + b; + case TOKEN.sub: + return a - b; + case TOKEN.mul: + return a * b; + case TOKEN.div: + return b !== 0 ? a / b : 0; // div by zero → 0 + case TOKEN.idiv: + return b !== 0 ? Math.trunc(a / b) : 0; // div by zero → 0 + case TOKEN.mod: + return b !== 0 ? a - Math.trunc(a / b) * b : 0; // div by zero → 0 + case TOKEN.exp: { + const r = a ** b; + return Number.isFinite(r) ? r : undefined; + } + case TOKEN.atan: { + // PostScript: dy dx atan → angle in degrees in [0, 360) + let deg = Math.atan2(a, b) * (180 / Math.PI); + if (deg < 0) { + deg += 360; + } + return deg; + } + case TOKEN.eq: + return a === b; + case TOKEN.ne: + return a !== b; + case TOKEN.gt: + return a > b; + case TOKEN.ge: + return a >= b; + case TOKEN.lt: + return a < b; + case TOKEN.le: + return a <= b; + case TOKEN.and: + return typeof a === "boolean" ? a && b : (a & b) | 0; + case TOKEN.or: + return typeof a === "boolean" ? a || b : a | b | 0; + case TOKEN.xor: + return typeof a === "boolean" ? a !== b : (a ^ b) | 0; + case TOKEN.bitshift: + return b >= 0 ? (a << b) | 0 : (a >> -b) | 0; + case TOKEN.min: + return Math.min(a, b); + case TOKEN.max: + return Math.max(a, b); + default: + return undefined; + } +} + +/** + * Evaluate a unary PostScript operator on a compile-time-known value. + * Returns `undefined` when the operation cannot be safely folded. + */ +function _evalUnaryConst(op, v) { + switch (op) { + case TOKEN.abs: + return Math.abs(v); + case TOKEN.neg: + return -v; + case TOKEN.ceiling: + return Math.ceil(v); + case TOKEN.floor: + return Math.floor(v); + case TOKEN.round: + return Math.round(v); + case TOKEN.truncate: + return Math.trunc(v); + case TOKEN.sqrt: { + const r = Math.sqrt(v); + return Number.isFinite(r) ? r : undefined; + } + case TOKEN.sin: + return Math.sin(((v % 360) * Math.PI) / 180); + case TOKEN.cos: + return Math.cos(((v % 360) * Math.PI) / 180); + case TOKEN.ln: { + const r = Math.log(v); + return Number.isFinite(r) ? r : undefined; + } + case TOKEN.log: { + const r = Math.log10(v); + return Number.isFinite(r) ? r : undefined; + } + case TOKEN.cvi: + return Math.trunc(v); + case TOKEN.cvr: + return v; + case TOKEN.not: + return typeof v === "boolean" ? !v : ~v; + default: + return undefined; + } +} + +// Maximum number of nodes allowed on the virtual stack at any point during +// the stack-to-tree conversion. Programs that exceed this are rejected. +const MAX_STACK_SIZE = 100; + +// Determine the PS_VALUE_TYPE of a unary operation's result. +// `not` propagates its operand's type (boolean not → boolean, integer not → +// numeric); every other unary op always yields a numeric result. +function _unaryValueType(op, operandType) { + return op === TOKEN.not ? operandType : PS_VALUE_TYPE.numeric; +} + +// Determine the PS_VALUE_TYPE of a binary operation's result. +function _binaryValueType(op, firstType, secondType) { + switch (op) { + // Comparison operators always produce a boolean. + case TOKEN.eq: + case TOKEN.ne: + case TOKEN.gt: + case TOKEN.ge: + case TOKEN.lt: + case TOKEN.le: + return PS_VALUE_TYPE.boolean; + // and / or / xor preserve the type when both operands are the same known + // type (both boolean or both numeric); otherwise the type is unknown. + case TOKEN.and: + case TOKEN.or: + case TOKEN.xor: + return firstType === secondType && firstType !== PS_VALUE_TYPE.unknown + ? firstType + : PS_VALUE_TYPE.unknown; + // All arithmetic / bitshift operators produce a numeric result. + default: + return PS_VALUE_TYPE.numeric; + } +} + +/** + * Converts a stack-based PostScript parser AST (PsProgram) into a stack-free + * expression tree. + * + * The virtual operand stack is initialized with one PsArgNode per function + * input; each instruction then manipulates the stack just as it would at + * runtime, but instead of numbers the stack holds tree nodes. + * + * Algebraic optimizations are applied eagerly as each node is constructed: + * constant folding, identity/absorbing elements, and double-negation + * elimination. + * + * When the program finishes the remaining stack entries are the output + * expressions — one per function output channel. + * + * Usage: + * const outputs = new PSStackToTree().evaluate(program, numInputs); + */ +class PSStackToTree { + static #binaryOps = null; + + static #unaryOps = null; + + static #idempotentUnary = null; + + static #negatedComparison = null; + + static #init() { + // Binary operator ids — used by _evalOp. + PSStackToTree.#binaryOps = new Set([ + TOKEN.add, + TOKEN.sub, + TOKEN.mul, + TOKEN.div, + TOKEN.idiv, + TOKEN.mod, + TOKEN.exp, + TOKEN.atan, + TOKEN.eq, + TOKEN.ne, + TOKEN.gt, + TOKEN.ge, + TOKEN.lt, + TOKEN.le, + TOKEN.and, + TOKEN.or, + TOKEN.xor, + TOKEN.bitshift, + ]); + // Unary operator ids. + PSStackToTree.#unaryOps = new Set([ + TOKEN.abs, + TOKEN.neg, + TOKEN.ceiling, + TOKEN.floor, + TOKEN.round, + TOKEN.truncate, + TOKEN.sqrt, + TOKEN.sin, + TOKEN.cos, + TOKEN.ln, + TOKEN.log, + TOKEN.cvi, + TOKEN.cvr, + TOKEN.not, + ]); + // Unary operators where f(f(x)) = f(x) — applying them twice is the same + // as applying them once. + PSStackToTree.#idempotentUnary = new Set([ + TOKEN.abs, + TOKEN.ceiling, + TOKEN.cvi, + TOKEN.cvr, + TOKEN.floor, + TOKEN.round, + TOKEN.truncate, + ]); + // Maps each comparison operator to its logical negation. + // Used to simplify not(comparison) → negated-comparison. + PSStackToTree.#negatedComparison = new Map([ + [TOKEN.eq, TOKEN.ne], + [TOKEN.ne, TOKEN.eq], + [TOKEN.lt, TOKEN.ge], + [TOKEN.le, TOKEN.gt], + [TOKEN.gt, TOKEN.le], + [TOKEN.ge, TOKEN.lt], + ]); + } + + /** + * @param {PsProgram} program + * @param {number} numInputs — number of domain values placed on the stack + * before the program runs (i.e. the length of the domain array / 2). + * @returns {Array} — one tree node per output value. + */ + evaluate(program, numInputs) { + if (!PSStackToTree.#binaryOps) { + PSStackToTree.#init(); + } + this._failed = false; + if (numInputs > MAX_STACK_SIZE) { + return null; + } + const stack = []; + for (let i = 0; i < numInputs; i++) { + stack.push(new PsArgNode(i)); + } + this._evalBlock(program.body, stack); + return this._failed ? null : stack; + } + + _evalBlock(block, stack) { + this._evalBlockFrom(block.instructions, 0, stack); + } + + /** + * Core evaluation loop. Processes `instructions[startIdx…]` in order, + * mutating `stack` as each instruction executes. + * + * When a `{ body } if` instruction grows the stack (the PostScript "early + * exit / guard" idiom), the remaining instructions in the current array are + * evaluated on **both** the true-branch stack and the false-branch stack, + * then the two results are merged into PsTernaryNodes. This handles + * patterns like: + * + * cond { pop R G B sentinel } if + * … more guards … + * sentinel 0 gt { defaultR defaultG defaultB } if + */ + _evalBlockFrom(instructions, startIdx, stack) { + for (let idx = startIdx; idx < instructions.length; idx++) { + if (this._failed) { + break; + } + const instr = instructions[idx]; + switch (instr.type) { + case PS_NODE.number: + stack.push(new PsConstNode(instr.value)); + if (stack.length > MAX_STACK_SIZE) { + this._failed = true; + } + break; + + case PS_NODE.operator: + this._evalOp(instr.op, stack); + break; + + case PS_NODE.if: { + // Pop condition, snapshot the stack, run the then-block on a copy, + // then merge. + if (stack.length < 1) { + this._failed = true; + break; + } + const cond = stack.pop(); + const saved = stack.slice(); + this._evalBlock(instr.then, stack); + if (this._failed) { + break; + } + if (stack.length === saved.length) { + // Normal case: depth preserved — positions that changed become + // PsTernaryNode(cond, thenValue, originalValue). + for (let i = 0; i < stack.length; i++) { + if (stack[i] !== saved[i]) { + stack[i] = this._makeTernary(cond, stack[i], saved[i]); + } + } + } else if (stack.length > saved.length) { + // "Guard / early-exit" pattern: the if-body pushed extra values. + if (cond.type === PS_NODE.const) { + // Condition is a compile-time constant: short-circuit without + // forking. For a false condition restore the saved stack; for a + // true condition keep the body result already on `stack`. + if (!cond.value) { + stack.length = 0; + stack.push(...saved); + } + break; + } + // Non-constant condition: evaluate the *rest* of this block on + // both the true-branch stack and the false-branch stack, then + // merge the two results into PsTernaryNodes. + const trueStack = stack.slice(); + this._evalBlockFrom(instructions, idx + 1, trueStack); + if (this._failed) { + break; + } + const falseStack = saved; + this._evalBlockFrom(instructions, idx + 1, falseStack); + if (this._failed) { + break; + } + if (trueStack.length !== falseStack.length) { + // The two paths produced different stack depths. For + // well-formed PostScript functions this happens when the + // remaining code still has a "default value" guard that fires + // unconditionally for one path but not the other. Pad the + // shorter result with PsConstNode(0) so both have the same + // length; the padding zeros end up in ternary branches that + // are never selected at runtime. + const zero = new PsConstNode(0); + while (trueStack.length < falseStack.length) { + trueStack.push(zero); + } + while (falseStack.length < trueStack.length) { + falseStack.push(zero); + } + } + stack.length = 0; + for (let i = 0; i < trueStack.length; i++) { + stack.push(this._makeTernary(cond, trueStack[i], falseStack[i])); + } + return; // Remaining instructions already consumed above. + } else { + // Stack-shrinking if — cannot represent as a tree. + this._failed = true; + } + break; + } + + case PS_NODE.ifelse: { + // Pop condition; run each branch on an independent copy of the + // current stack; zip the two resulting stacks into PsTernaryNodes + // wherever the branches disagree. + if (stack.length < 1) { + this._failed = true; + break; + } + const cond = stack.pop(); + const snapshot = stack.slice(); + + const thenStack = snapshot.slice(); + this._evalBlock(instr.then, thenStack); + if (this._failed) { + break; + } + + const elseStack = snapshot.slice(); + this._evalBlock(instr.otherwise, elseStack); + if (this._failed) { + break; + } + + if (thenStack.length !== elseStack.length) { + // Pad the shorter branch with zeros so both have the same depth. + // For well-formed functions the extra zeros land in branches that + // are never selected at runtime. + const zero = new PsConstNode(0); + while (thenStack.length < elseStack.length) { + thenStack.push(zero); + } + while (elseStack.length < thenStack.length) { + elseStack.push(zero); + } + } + stack.length = 0; + for (let i = 0; i < thenStack.length; i++) { + stack.push(this._makeTernary(cond, thenStack[i], elseStack[i])); + } + break; + } + } + } + } + + _evalOp(op, stack) { + if (PSStackToTree.#binaryOps.has(op)) { + if (stack.length < 2) { + this._failed = true; + return; + } + const first = stack.pop(); + const second = stack.pop(); + stack.push(this._makeBinary(op, first, second)); + return; + } + + if (PSStackToTree.#unaryOps.has(op)) { + if (stack.length < 1) { + this._failed = true; + return; + } + stack.push(this._makeUnary(op, stack.pop())); + return; + } + + switch (op) { + case TOKEN.true: + stack.push(new PsConstNode(true)); + if (stack.length > MAX_STACK_SIZE) { + this._failed = true; + } + break; + + case TOKEN.false: + stack.push(new PsConstNode(false)); + if (stack.length > MAX_STACK_SIZE) { + this._failed = true; + } + break; + + case TOKEN.dup: + if (stack.length < 1) { + this._failed = true; + break; + } + stack.push(stack.at(-1)); + if (stack.length > MAX_STACK_SIZE) { + this._failed = true; + } + break; + + case TOKEN.exch: { + if (stack.length < 2) { + this._failed = true; + break; + } + const a = stack.pop(); + const b = stack.pop(); + stack.push(a, b); + break; + } + + case TOKEN.pop: + if (stack.length < 1) { + this._failed = true; + break; + } + stack.pop(); + break; + + case TOKEN.copy: { + if (stack.length < 1) { + this._failed = true; + break; + } + const nNode = stack.pop(); + if (nNode.type === PS_NODE.const) { + const n = nNode.value | 0; + if (n === 0) { + // n === 0 is a no-op + } else if (n < 0 || n > stack.length) { + this._failed = true; + } else { + stack.push(...stack.slice(-n)); + if (stack.length > MAX_STACK_SIZE) { + this._failed = true; + } + } + } else { + // Runtime n — cannot resolve at compile time. + this._failed = true; + } + break; + } + + case TOKEN.index: { + if (stack.length < 1) { + this._failed = true; + break; + } + const nNode = stack.pop(); + if (nNode.type === PS_NODE.const) { + const n = nNode.value | 0; + if (n < 0 || n >= stack.length) { + this._failed = true; + } else { + // 0 index = dup of top; n index = copy of nth element from top + stack.push(stack.at(-n - 1)); + } + } else { + // Runtime n — cannot resolve at compile time. + this._failed = true; + } + break; + } + + case TOKEN.roll: { + if (stack.length < 2) { + this._failed = true; + break; + } + const jNode = stack.pop(); + const nNode = stack.pop(); + if (nNode.type === PS_NODE.const && jNode.type === PS_NODE.const) { + const n = nNode.value | 0; + if (n === 0) { + // n === 0 is a no-op + } else if (n < 0 || n > stack.length) { + this._failed = true; + } else { + // Normalize j into [0, n): positive j moves the top element(s) to + // the bottom of the window. + const j = (((jNode.value | 0) % n) + n) % n; + if (j > 0) { + const slice = stack.splice(-n, n); + // slice[n-j…n-1] → new bottom; slice[0…n-j-1] → new top. + stack.push(...slice.slice(n - j), ...slice.slice(0, n - j)); + } + } + } else { + // Runtime n or j — cannot resolve at compile time. + this._failed = true; + } + break; + } + + default: + this._failed = true; + break; + } + } + + /** + * Create a binary tree node, applying optimizations eagerly: + * + * 1. Constant folding — both operands are PsConstNode → fold to PsConstNode. + * 2. Reflexive simplifications — x−x→0, x xor x→0, x eq x→true, etc. + * 3. Algebraic simplifications with one known operand — identity elements + * (x+0→x, x*1→x, …), absorbing elements (x*0→0, x and false→false, …), + * and strength reductions (x*-1→neg(x), x^0.5→sqrt(x), x^2→x*x, …). + * + * Recall: `first` was on top of the stack (right operand for non-commutative + * ops), `second` was below (left operand). So `a b sub` → second=a, first=b + * → a − b. + */ + _makeBinary(op, first, second) { + // 1. Constant folding + if (first.type === PS_NODE.const && second.type === PS_NODE.const) { + const v = _evalBinaryConst(op, second.value, first.value); + if (v !== undefined) { + return new PsConstNode(v); + } + } + + // 2. Reflexive simplifications: both operands are the same expression. + if (_nodesEqual(first, second)) { + switch (op) { + case TOKEN.sub: + return new PsConstNode(0); // x − x → 0 + case TOKEN.xor: + // Boolean operands: true xor true = false xor false = false. + // Integer operands: n xor n = 0. + return new PsConstNode( + first.valueType === PS_VALUE_TYPE.boolean ? false : 0 + ); + // TOKEN.mod, TOKEN.div, TOKEN.idiv are NOT simplified here: + // x op x is undefined when x = 0, so we cannot fold without knowing + // that x is non-zero. + case TOKEN.and: + case TOKEN.or: + return first; // x and x → x; x or x → x + case TOKEN.min: + case TOKEN.max: + return first; // min(x,x) → x; max(x,x) → x + case TOKEN.eq: + case TOKEN.ge: + case TOKEN.le: + return new PsConstNode(true); + case TOKEN.ne: + case TOKEN.gt: + case TOKEN.lt: + return new PsConstNode(false); + } + } + + // 3. Algebraic simplifications — b = first.value, a = second.value. + if (first.type === PS_NODE.const) { + const b = first.value; + switch (op) { + case TOKEN.add: + if (b === 0) { + return second; // x + 0 → x + } + break; + case TOKEN.sub: + if (b === 0) { + return second; // x − 0 → x + } + break; + case TOKEN.mul: + if (b === 1) { + return second; // x * 1 → x + } + if (b === 0) { + return first; // x * 0 → 0 (reuse the PsConstNode(0)) + } + if (b === -1) { + return this._makeUnary(TOKEN.neg, second); // x * -1 → neg(x) + } + break; + case TOKEN.div: + // x / c → x * (1/c): replace division by a constant with the + // equivalent multiplication (1/1=1 is caught by the mul identity). + if (b !== 0) { + return this._makeBinary(TOKEN.mul, new PsConstNode(1 / b), second); + } + break; + case TOKEN.idiv: + if (b === 1) { + return second; // x idiv 1 → x + } + break; + case TOKEN.exp: + if (b === 1) { + return second; // x ^ 1 → x + } + if (b === -1) { + return this._makeBinary(TOKEN.div, second, new PsConstNode(1)); + } + if (b === 0.5) { + return this._makeUnary(TOKEN.sqrt, second); // x ^ 0.5 → sqrt(x) + } + if (b === 0.25) { + // x ^ 0.25 → sqrt(sqrt(x)): two native f64.sqrt calls instead + // of the pow() import. + const sqrtOnce = this._makeUnary(TOKEN.sqrt, second); + return this._makeUnary(TOKEN.sqrt, sqrtOnce); + } + if (b === 2) { + // x ^ 2 → x * x: avoids the pow() import call entirely. + return this._makeBinary(TOKEN.mul, second, second); + } + if (b === 3) { + // x ^ 3 → (x * x) * x: avoids the pow() import call entirely. + return this._makeBinary( + TOKEN.mul, + this._makeBinary(TOKEN.mul, second, second), + second + ); + } + if (b === 4) { + // x ^ 4 → (x * x) * (x * x): avoids the pow() import call entirely. + const square = this._makeBinary(TOKEN.mul, second, second); + return this._makeBinary(TOKEN.mul, square, square); + } + if (b === 0) { + return new PsConstNode(1); // x ^ 0 → 1 + } + break; + case TOKEN.and: + if (b === true) { + return second; // x and true → x + } + if (b === false) { + return first; // x and false → false + } + break; + case TOKEN.or: + if (b === false) { + return second; // x or false → x + } + if (b === true) { + return first; // x or true → true + } + break; + case TOKEN.min: + // min(max(x, c2), c1) where c2 ≥ c1 → c1: + // max(x, c2) ≥ c2 ≥ c1, so min with c1 always returns c1. + if ( + second.type === PS_NODE.binary && + second.op === TOKEN.max && + second.first.type === PS_NODE.const && + second.first.value >= b + ) { + return first; + } + break; + case TOKEN.max: + // max(min(x, c1), c2) where c2 ≥ c1 → c2: + // min(x, c1) ≤ c1 ≤ c2, so max with c2 always returns c2. + if ( + second.type === PS_NODE.binary && + second.op === TOKEN.min && + second.first.type === PS_NODE.const && + second.first.value <= b + ) { + return first; + } + break; + } + } + + if (second.type === PS_NODE.const) { + const a = second.value; + switch (op) { + case TOKEN.add: + if (a === 0) { + return first; // 0 + x → x + } + break; + case TOKEN.sub: + if (a === 0) { + return this._makeUnary(TOKEN.neg, first); // 0 − x → neg(x) + } + break; + case TOKEN.mul: + if (a === 1) { + return first; // 1 * x → x + } + if (a === 0) { + return second; // 0 * x → 0 (reuse the PsConstNode(0)) + } + if (a === -1) { + return this._makeUnary(TOKEN.neg, first); // -1 * x → neg(x) + } + break; + case TOKEN.and: + if (a === true) { + return first; // true and x → x + } + if (a === false) { + return second; // false and x → false + } + break; + case TOKEN.or: + if (a === false) { + return first; // false or x → x + } + if (a === true) { + return second; // true or x → true + } + break; + } + } + + return new PsBinaryNode( + op, + first, + second, + _binaryValueType(op, first.valueType, second.valueType) + ); + } + + /** + * Create a unary tree node, applying optimizations eagerly: + * + * 1. Constant folding. + * 2. not(comparison) → negated comparison: not(a eq b) → a ne b, etc. + * 3. neg(a − b) → b − a. + * 4. Double-negation: neg(neg(x)) → x, not(not(x)) → x. + * 5. abs(neg(x)) → abs(x). + * 6. Idempotent: f(f(x)) → f(x) for abs, ceiling, floor, round, etc. + */ + _makeUnary(op, operand) { + // 1. Constant folding + if (operand.type === PS_NODE.const) { + const v = _evalUnaryConst(op, operand.value); + if (v !== undefined) { + return new PsConstNode(v); + } + } + + // 2. + if (op === TOKEN.not && operand.type === PS_NODE.binary) { + const negated = PSStackToTree.#negatedComparison.get(operand.op); + if (negated !== undefined) { + return new PsBinaryNode( + negated, + operand.first, + operand.second, + PS_VALUE_TYPE.boolean + ); + } + } + + // 3. (_makeBinary may fold further if one operand is 0) + if ( + op === TOKEN.neg && + operand.type === PS_NODE.binary && + operand.op === TOKEN.sub + ) { + return this._makeBinary(TOKEN.sub, operand.second, operand.first); + } + + if (operand.type === PS_NODE.unary) { + // 4. (not(not(x)) only reachable when x is not a comparison) + if ( + (op === TOKEN.neg && operand.op === TOKEN.neg) || + (op === TOKEN.not && operand.op === TOKEN.not) + ) { + return operand.operand; + } + // 5. + if (op === TOKEN.abs && operand.op === TOKEN.neg) { + return this._makeUnary(TOKEN.abs, operand.operand); + } + // 6. + if (PSStackToTree.#idempotentUnary.has(op) && op === operand.op) { + return operand; + } + } + + return new PsUnaryNode(op, operand, _unaryValueType(op, operand.valueType)); + } + + /** + * Create a ternary node, applying optimizations eagerly: + * + * 1. Constant condition — fold to the live branch. + * 2. Identical branches — the condition is irrelevant, return either branch. + * 3. Boolean branch constants — `cond ? true : false` → cond, + * `cond ? false : true` → not(cond). + * 4. Ternary → branchless min/max when the condition compares two numeric + * expressions that are also the two branches. + */ + _makeTernary(cond, then, otherwise) { + // 1. Constant condition + if (cond.type === PS_NODE.const) { + return cond.value ? then : otherwise; + } + // 2. Both branches are the same expression + if (_nodesEqual(then, otherwise)) { + return then; + } + // 3. Boolean branch constants + if (then.type === PS_NODE.const && otherwise.type === PS_NODE.const) { + if (then.value === true && otherwise.value === false) { + return cond; // cond ? true : false → cond + } + if (then.value === false && otherwise.value === true) { + return this._makeUnary(TOKEN.not, cond); // cond ? false : true → !cond + } + } + + // 4. Ternary → branchless min/max folding. + // + // When the condition is a numeric comparison between two expressions A and + // B, and the two branches are exactly those two expressions (in some + // order), the ternary collapses to a single f64.min / f64.max instruction: + // + // (A gt B) ? B : A → min(A, B) (A ge B) ? B : A → min(A, B) + // (A lt B) ? B : A → max(A, B) (A le B) ? B : A → max(A, B) + // (A gt B) ? A : B → max(A, B) (A ge B) ? A : B → max(A, B) + // (A lt B) ? A : B → min(A, B) (A le B) ? A : B → min(A, B) + // + // Here A = cond.second (left operand) and B = cond.first (right operand), + // following the PS stack convention: `A B gt` → second=A, first=B. + if (cond.type === PS_NODE.binary) { + const { op: cop, first: cf, second: cs } = cond; + if (cop === TOKEN.gt || cop === TOKEN.ge) { + // cond: cs > cf + if (_nodesEqual(then, cf) && _nodesEqual(otherwise, cs)) { + return this._makeBinary(TOKEN.min, cf, cs); // cs>cf ? cf:cs → min + } + if (_nodesEqual(then, cs) && _nodesEqual(otherwise, cf)) { + return this._makeBinary(TOKEN.max, cf, cs); // cs>cf ? cs:cf → max + } + } else if (cop === TOKEN.lt || cop === TOKEN.le) { + // cond: cs < cf + if (_nodesEqual(then, cf) && _nodesEqual(otherwise, cs)) { + return this._makeBinary(TOKEN.max, cf, cs); // cs= TOKEN.true && id <= TOKEN.ifelse; + const token = new Token(id, isOperator ? name : null); + singletons[name] = token; + if (isOperator) { + operatorSingletons[name] = token; + } + } + Lexer.#singletons = singletons; + Lexer.#operatorSingletons = operatorSingletons; + } + + constructor(data) { + if (!Lexer.#singletons) { + Lexer.#initSingletons(); + } + this.data = data; + this.pos = 0; + this.len = data.length; + // Sticky regexes: set lastIndex before exec() to match at an exact offset. + this._numberPattern = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y; + this._identifierPattern = /[a-z]+/y; + } + + // Skip a % comment, advancing past the next \n or \r (or to EOF). + _skipComment() { + const lf = this.data.indexOf("\n", this.pos); + const cr = this.data.indexOf("\r", this.pos); + // Treat a missing EOL as this.len so Math.min picks the one that exists. + const eol = Math.min(lf < 0 ? this.len : lf, cr < 0 ? this.len : cr); + this.pos = Math.min(eol + 1, this.len); + } + + _getNumber() { + this._numberPattern.lastIndex = this.pos; + const match = this._numberPattern.exec(this.data); + if (!match) { + return new Token(TOKEN.number, 0); + } + const number = parseFloat(match[0]); + if (!Number.isFinite(number)) { + return new Token(TOKEN.number, 0); + } + this.pos = this._numberPattern.lastIndex; + return new Token(TOKEN.number, number); + } + + _getOperator() { + this._identifierPattern.lastIndex = this.pos; + const match = this._identifierPattern.exec(this.data); + if (!match) { + return new Token(TOKEN.number, 0); + } + this.pos = this._identifierPattern.lastIndex; + const op = match[0]; + const token = Lexer.#operatorSingletons[op]; + if (!token) { + return new Token(TOKEN.number, 0); + } + return token; + } + + // Return the next token, or Lexer.#singletons.eof at end of input. + next() { + while (this.pos < this.len) { + const ch = this.data.charCodeAt(this.pos++); + switch (ch) { + // PostScript white-space characters (PDF32000 §7.2.2) + case 0x00 /* NUL */: + case 0x09 /* HT */: + case 0x0a /* LF */: + case 0x0c /* FF */: + case 0x0d /* CR */: + case 0x20 /* SP */: + break; + + case 0x25 /* % — comment */: + this._skipComment(); + break; + + case 0x7b /* { */: + return Lexer.#singletons.lbrace; + case 0x7d /* } */: + return Lexer.#singletons.rbrace; + + case 0x2b /* + */: + case 0x2d /* - */: + this.pos--; + return this._getNumber(); + + case 0x2e /* . */: + this.pos--; + return this._getNumber(); + + default: + if (ch >= 0x30 /* 0 */ && ch <= 0x39 /* 9 */) { + this.pos--; + return this._getNumber(); + } + if (ch >= 0x61 /* a */ && ch <= 0x7a /* z */) { + this.pos--; + return this._getOperator(); + } + return new Token(TOKEN.number, 0); + } + } + return Lexer.#singletons.eof; + } +} + +export { Lexer, Token, TOKEN }; diff --git a/src/core/postscript/wasm_compiler.js b/src/core/postscript/wasm_compiler.js new file mode 100644 index 000000000..f792a3e64 --- /dev/null +++ b/src/core/postscript/wasm_compiler.js @@ -0,0 +1,1065 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + parsePostScriptFunction, + PS_NODE, + PS_VALUE_TYPE, + PSStackToTree, +} from "./ast.js"; +import { TOKEN } from "./lexer.js"; + +// Wasm opcodes — https://webassembly.github.io/spec/core/binary/instructions.html +const OP = { + if: 0x04, + else: 0x05, + end: 0x0b, + select: 0x1b, + call: 0x10, + local_get: 0x20, + local_set: 0x21, + local_tee: 0x22, + i32_const: 0x41, + i32_eqz: 0x45, + i32_and: 0x71, + i32_or: 0x72, + i32_xor: 0x73, + i32_shl: 0x74, + i32_shr_s: 0x75, + i32_trunc_f64_s: 0xaa, + f64_const: 0x44, + f64_eq: 0x61, + f64_ne: 0x62, + f64_lt: 0x63, + f64_gt: 0x64, + f64_le: 0x65, + f64_ge: 0x66, + f64_abs: 0x99, + f64_neg: 0x9a, + f64_ceil: 0x9b, + f64_floor: 0x9c, + f64_trunc: 0x9d, + f64_nearest: 0x9e, + f64_sqrt: 0x9f, + f64_add: 0xa0, + f64_sub: 0xa1, + f64_mul: 0xa2, + f64_div: 0xa3, + f64_min: 0xa4, + f64_max: 0xa5, + f64_convert_i32_s: 0xb7, + f64_store: 0x39, +}; + +// https://webassembly.github.io/spec/core/binary/types.html#binary-comptype +const FUNC_TYPE = 0x60; +// https://webassembly.github.io/spec/core/binary/types.html#binary-valtype +const F64 = 0x7c; + +// https://webassembly.github.io/spec/core/binary/modules.html +const SECTION = { + type: 0x01, + import: 0x02, + function: 0x03, + memory: 0x05, + export: 0x07, + code: 0x0a, +}; + +// https://webassembly.github.io/spec/core/binary/modules.html#binary-importdesc +const EXTERN_FUNC = 0x00; +// https://webassembly.github.io/spec/core/binary/modules.html#binary-exportdesc +const EXTERN_MEM = 0x02; + +// https://webassembly.github.io/spec/core/binary/values.html#binary-int (unsigned LEB128) +function unsignedLEB128(n) { + const out = []; + do { + let byte = n & 0x7f; + n >>>= 7; + if (n !== 0) { + byte |= 0x80; + } + out.push(byte); + } while (n !== 0); + return out; +} + +function encodeASCIIString(s) { + return [...unsignedLEB128(s.length), ...Array.from(s, c => c.charCodeAt(0))]; +} + +function section(id, data) { + return [id, ...unsignedLEB128(data.length), ...data]; +} + +function vec(items) { + const out = unsignedLEB128(items.length); + for (const item of items) { + if (typeof item === "number") { + out.push(item); + continue; + } + for (const byte of item) { + out.push(byte); + } + } + return out; +} + +// Math functions unavailable as Wasm instructions — imported from JS. +const MATH_IMPORTS = [ + // name | module | field | params | results + ["sin", "Math", "sin", [F64], [F64]], + ["cos", "Math", "cos", [F64], [F64]], + // atan2(dy, dx) — PS atan takes (dy dx) in that order + ["atan2", "Math", "atan2", [F64, F64], [F64]], + ["log", "Math", "log", [F64], [F64]], // natural log + ["log10", "Math", "log10", [F64], [F64]], + // pow(base, exp) + ["pow", "Math", "pow", [F64, F64], [F64]], +]; + +// Import object for WebAssembly instantiation — only the functions declared +// in MATH_IMPORTS, keyed by their field name. +const _mathImportObject = { + Math: Object.fromEntries(MATH_IMPORTS.map(([name]) => [name, Math[name]])), +}; + +// The compiler. +// +// After PSStackToTree converts the parser AST into a stack-free expression +// tree, the compiler walks each output tree node recursively and emits Wasm +// instructions that leave exactly one f64 value on the Wasm operand stack. +// PsTernaryNode compiles to a value-returning `if/else/end` block — no +// branch-buffer swapping or local-merging is needed. +class PsWasmCompiler { + static #initialized = false; + + static #comparisonToOp = null; + + static #importIdx = null; + + static #degToRad = 0; + + static #radToDeg = 0; + + static #importTypeEntries = null; + + static #importSection = null; + + static #functionSection = null; + + static #memorySection = null; + + static #exportSection = null; + + static #wasmMagicVersion = null; + + // Shared buffer for f64 encoding — avoids per-call allocation. + static #f64View = null; + + static #f64Arr = null; + + static #init() { + // TOKEN comparison ids → Wasm f64 comparison opcodes (leave i32 on stack). + PsWasmCompiler.#comparisonToOp = new Map([ + [TOKEN.eq, OP.f64_eq], + [TOKEN.ne, OP.f64_ne], + [TOKEN.lt, OP.f64_lt], + [TOKEN.le, OP.f64_le], + [TOKEN.gt, OP.f64_gt], + [TOKEN.ge, OP.f64_ge], + ]); + // Index of each import function by name. + PsWasmCompiler.#importIdx = Object.create(null); + for (let i = 0; i < MATH_IMPORTS.length; i++) { + PsWasmCompiler.#importIdx[MATH_IMPORTS[i][0]] = i; + } + PsWasmCompiler.#degToRad = Math.PI / 180; + PsWasmCompiler.#radToDeg = 180 / Math.PI; + // Import type entries are identical on every compilation — compute once. + PsWasmCompiler.#importTypeEntries = MATH_IMPORTS.map( + ([, , , params, results]) => [FUNC_TYPE, ...vec(params), ...vec(results)] + ); + // Static Wasm sections that never change between compilations. + PsWasmCompiler.#importSection = new Uint8Array( + section( + SECTION.import, + vec( + MATH_IMPORTS.map(([, mod, field], i) => [ + ...encodeASCIIString(mod), + ...encodeASCIIString(field), + EXTERN_FUNC, // import kind: function + ...unsignedLEB128(i + 1), // type index (0 = main func type) + ]) + ) + ) + ); + // One function, type index 0. + PsWasmCompiler.#functionSection = new Uint8Array( + section(SECTION.function, vec([[0]])) + ); + // Min 1 page (64 KiB), no max. + // https://webassembly.github.io/spec/core/binary/types.html#binary-limits + PsWasmCompiler.#memorySection = new Uint8Array( + section(SECTION.memory, vec([[0x00, 0x01]])) + ); + // Export "fn" (func index = nImports) and "mem" (memory) for the wrapper. + PsWasmCompiler.#exportSection = new Uint8Array( + section( + SECTION.export, + vec([ + [ + ...encodeASCIIString("fn"), + EXTERN_FUNC, + ...unsignedLEB128(MATH_IMPORTS.length), + ], + [...encodeASCIIString("mem"), EXTERN_MEM, 0x00], + ]) + ) + ); + // Wasm binary magic + version (constant). + // https://webassembly.github.io/spec/core/binary/modules.html#binary-magic + PsWasmCompiler.#wasmMagicVersion = new Uint8Array([ + 0x00, + 0x61, + 0x73, + 0x6d, // \0asm + 0x01, + 0x00, + 0x00, + 0x00, // version 1 + ]); + const f64Buf = new ArrayBuffer(8); + PsWasmCompiler.#f64View = new DataView(f64Buf); + PsWasmCompiler.#f64Arr = new Uint8Array(f64Buf); + PsWasmCompiler.#initialized = true; + } + + constructor(domain, range) { + if (!PsWasmCompiler.#initialized) { + PsWasmCompiler.#init(); + } + this._nIn = domain.length >> 1; + this._nOut = range.length >> 1; + this._range = range; + this._code = []; + // Params 0..nIn-1 are automatically locals; extras start at _nextLocal. + this._nextLocal = this._nIn; + this._freeLocals = []; + } + + // Wasm emit helpers + + _allocLocal() { + return this._freeLocals.pop() ?? this._nextLocal++; + } + + _releaseLocal(idx) { + this._freeLocals.push(idx); + } + + _emitULEB128(n) { + do { + let b = n & 0x7f; + n >>>= 7; + if (n !== 0) { + b |= 0x80; + } + this._code.push(b); + } while (n !== 0); + } + + _emitF64Const(value) { + this._code.push(OP.f64_const); + PsWasmCompiler.#f64View.setFloat64(0, value, true /* little-endian */); + for (let i = 0; i < 8; i++) { + this._code.push(PsWasmCompiler.#f64Arr[i]); + } + } + + _emitLocalGet(idx) { + this._code.push(OP.local_get); + this._emitULEB128(idx); + } + + _emitLocalSet(idx) { + this._code.push(OP.local_set); + this._emitULEB128(idx); + } + + _emitLocalTee(idx) { + this._code.push(OP.local_tee); + this._emitULEB128(idx); + } + + // Tree node compilation + + /** + * Emit Wasm instructions for `node`, leaving exactly one f64 on the Wasm + * operand stack. Returns false if the node cannot be compiled. + */ + _compileNode(node) { + switch (node.type) { + case PS_NODE.arg: + this._emitLocalGet(node.index); + return true; + + case PS_NODE.const: { + let v = node.value; + if (typeof v === "boolean") { + v = v ? 1 : 0; + } + this._emitF64Const(v); + return true; + } + + case PS_NODE.unary: + return this._compileUnaryNode(node); + + case PS_NODE.binary: + return this._compileBinaryNode(node); + + case PS_NODE.ternary: + return this._compileTernaryNode(node); + + default: + return false; + } + } + + _compileSinCosNode(node) { + // PS sin/cos take degrees; normalize mod 360 before converting to radians + // so that e.g. sin(360°) = 0, not Math.sin(2π) ≈ -2.4e-16. + const local = this._allocLocal(); + try { + if (!this._compileNode(node.operand)) { + return false; + } + const code = this._code; + this._emitLocalSet(local); + this._emitLocalGet(local); + this._emitLocalGet(local); + this._emitF64Const(360); + code.push(OP.f64_div, OP.f64_trunc); + this._emitF64Const(360); + code.push(OP.f64_mul, OP.f64_sub); // a mod 360 + this._emitF64Const(PsWasmCompiler.#degToRad); + code.push(OP.f64_mul, OP.call); + this._emitULEB128( + PsWasmCompiler.#importIdx[node.op === TOKEN.sin ? "sin" : "cos"] + ); + return true; + } finally { + this._releaseLocal(local); + } + } + + _compileUnaryNode(node) { + const code = this._code; + if (node.op === TOKEN.sin || node.op === TOKEN.cos) { + return this._compileSinCosNode(node); + } + + // `not` needs i32, not f64 — handle before the generic compilation below. + if (node.op === TOKEN.not) { + if (node.valueType === PS_VALUE_TYPE.boolean) { + if (!this._compileNodeAsBoolI32(node.operand)) { + return false; + } + code.push(OP.i32_eqz, OP.f64_convert_i32_s); + return true; + } + if (node.valueType === PS_VALUE_TYPE.numeric) { + // Bitwise NOT: ~n, implemented as n XOR -1. + // i32.const -1 encodes as the single signed-LEB128 byte 0x7f. + if (!this._compileNode(node.operand)) { + return false; + } + code.push( + OP.i32_trunc_f64_s, + OP.i32_const, + 0x7f, + OP.i32_xor, + OP.f64_convert_i32_s + ); + return true; + } + // Unknown type — cannot safely choose boolean or bitwise NOT. + return false; + } + + if (!this._compileNode(node.operand)) { + return false; + } + switch (node.op) { + case TOKEN.abs: + code.push(OP.f64_abs); + break; + case TOKEN.neg: + code.push(OP.f64_neg); + break; + case TOKEN.sqrt: + code.push(OP.f64_sqrt); + break; + case TOKEN.floor: + code.push(OP.f64_floor); + break; + case TOKEN.ceiling: + code.push(OP.f64_ceil); + break; + case TOKEN.round: + // PostScript `round` uses round-half-up (floor(x+0.5)), not the + // banker's rounding that Wasm f64.nearest implements. + this._emitF64Const(0.5); + code.push(OP.f64_add, OP.f64_floor); + break; + case TOKEN.truncate: + code.push(OP.f64_trunc); + break; + case TOKEN.cvi: + // Truncate toward zero, keep as f64. + code.push(OP.i32_trunc_f64_s, OP.f64_convert_i32_s); + break; + case TOKEN.cvr: + // No-op: already f64. + break; + case TOKEN.ln: + code.push(OP.call); + this._emitULEB128(PsWasmCompiler.#importIdx.log); + break; + case TOKEN.log: + code.push(OP.call); + this._emitULEB128(PsWasmCompiler.#importIdx.log10); + break; + default: + return false; + } + return true; + } + + _compileSafeDivNode(first, second) { + // Returns 0 when divisor == 0 (IEEE 754 gives ±Inf/NaN; pdfium returns 0). + const tmp = this._allocLocal(); + try { + if (!this._compileNode(second)) { + return false; + } + if (!this._compileNode(first)) { + return false; + } + const code = this._code; + this._emitLocalTee(tmp); + code.push(OP.f64_div); + this._emitF64Const(0); + this._emitLocalGet(tmp); + this._emitF64Const(0); + code.push(OP.f64_ne, OP.select); + return true; + } finally { + this._releaseLocal(tmp); + } + } + + _compileSafeIdivNode(first, second) { + // `trunc(second / first)` returning 0 when first == 0, matching pdfium. + // Same select pattern as _compileSafeDivNode with an extra f64_trunc. + const tmp = this._allocLocal(); + try { + if (!this._compileNode(second)) { + return false; + } + if (!this._compileNode(first)) { + return false; + } + const code = this._code; + this._emitLocalTee(tmp); + code.push(OP.f64_div, OP.f64_trunc); + this._emitF64Const(0); + this._emitLocalGet(tmp); + this._emitF64Const(0); + code.push(OP.f64_ne, OP.select); + return true; + } finally { + this._releaseLocal(tmp); + } + } + + _compileBitshiftNode(first, second) { + if (first.type !== PS_NODE.const || !Number.isInteger(first.value)) { + return false; + } + if (!this._compileNode(second)) { + return false; + } + + const code = this._code; + code.push(OP.i32_trunc_f64_s); + const shift = first.value; + if (shift > 0) { + code.push(OP.i32_const); + this._emitULEB128(shift); + code.push(OP.i32_shl); + } else if (shift < 0) { + code.push(OP.i32_const); + this._emitULEB128(-shift); + code.push(OP.i32_shr_s); + } + code.push(OP.f64_convert_i32_s); + return true; + } + + _compileModNode(first, second) { + // a mod 0 → 0, matching pdfium. Const b=0: a is computed but discarded. + if (first.type === PS_NODE.const && first.value === 0) { + if (!this._compileNode(second)) { + return false; + } + this._code.push(OP.drop); + this._emitF64Const(0); + return true; + } + + const localA = this._allocLocal(); + try { + if (!this._compileNode(second)) { + return false; + } + this._emitLocalTee(localA); + + const code = this._code; + if (first.type === PS_NODE.const) { + // b≠0 guaranteed (b=0 handled above). + this._emitLocalGet(localA); + this._emitF64Const(first.value); + code.push(OP.f64_div, OP.f64_trunc); + this._emitF64Const(first.value); + code.push(OP.f64_mul, OP.f64_sub); + } else { + const localB = this._allocLocal(); + try { + if (!this._compileNode(first)) { + return false; + } + this._emitLocalSet(localB); + this._emitLocalGet(localA); + this._emitLocalGet(localB); + code.push(OP.f64_div, OP.f64_trunc); + this._emitLocalGet(localB); + code.push(OP.f64_mul, OP.f64_sub); + // Guard: if b=0, return 0 instead of NaN. + this._emitF64Const(0); + this._emitLocalGet(localB); + this._emitF64Const(0); + code.push(OP.f64_ne, OP.select); + } finally { + this._releaseLocal(localB); + } + } + return true; + } finally { + this._releaseLocal(localA); + } + } + + _compileAtanNode(first, second) { + const localR = this._allocLocal(); + try { + if (!this._compileNode(second)) { + return false; + } + if (!this._compileNode(first)) { + return false; + } + + const code = this._code; + code.push(OP.call); + this._emitULEB128(PsWasmCompiler.#importIdx.atan2); + this._emitF64Const(PsWasmCompiler.#radToDeg); + code.push(OP.f64_mul); + + this._emitLocalTee(localR); + this._emitF64Const(0); + code.push(OP.f64_lt, OP.if, F64); + this._emitLocalGet(localR); + this._emitF64Const(360); + code.push(OP.f64_add, OP.else); + this._emitLocalGet(localR); + code.push(OP.end); + return true; + } finally { + this._releaseLocal(localR); + } + } + + _compileBitwiseNode(op, first, second) { + if (!this._compileBitwiseOperandI32(second)) { + return false; + } + if (!this._compileBitwiseOperandI32(first)) { + return false; + } + const code = this._code; + switch (op) { + case TOKEN.and: + code.push(OP.i32_and); + break; + case TOKEN.or: + code.push(OP.i32_or); + break; + case TOKEN.xor: + code.push(OP.i32_xor); + break; + default: + return false; + } + code.push(OP.f64_convert_i32_s); + return true; + } + + _compileBitwiseOperandI32(node) { + if (node.valueType === PS_VALUE_TYPE.boolean) { + return this._compileNodeAsBoolI32(node); + } + if (!this._compileNode(node)) { + return false; + } + this._code.push(OP.i32_trunc_f64_s); + return true; + } + + _compileStandardBinaryNode(op, first, second) { + // Identical compound operands: compile once, reuse via local_tee/local_get. + if ( + first === second && + first.type !== PS_NODE.arg && + first.type !== PS_NODE.const + ) { + const tmp = this._allocLocal(); + try { + if (!this._compileNode(first)) { + return false; + } + this._emitLocalTee(tmp); // [x] (also stores to tmp) + this._emitLocalGet(tmp); // [x, x] + } finally { + this._releaseLocal(tmp); + } + } else { + if (!this._compileNode(second)) { + return false; + } + if (!this._compileNode(first)) { + return false; + } + } + + const code = this._code; + switch (op) { + case TOKEN.add: + code.push(OP.f64_add); + break; + case TOKEN.sub: + code.push(OP.f64_sub); + break; + case TOKEN.mul: + code.push(OP.f64_mul); + break; + case TOKEN.exp: + code.push(OP.call); + this._emitULEB128(PsWasmCompiler.#importIdx.pow); + break; + case TOKEN.eq: + code.push(OP.f64_eq, OP.f64_convert_i32_s); + break; + case TOKEN.ne: + code.push(OP.f64_ne, OP.f64_convert_i32_s); + break; + case TOKEN.lt: + code.push(OP.f64_lt, OP.f64_convert_i32_s); + break; + case TOKEN.le: + code.push(OP.f64_le, OP.f64_convert_i32_s); + break; + case TOKEN.gt: + code.push(OP.f64_gt, OP.f64_convert_i32_s); + break; + case TOKEN.ge: + code.push(OP.f64_ge, OP.f64_convert_i32_s); + break; + case TOKEN.min: + code.push(OP.f64_min); + break; + case TOKEN.max: + code.push(OP.f64_max); + break; + default: + return false; + } + return true; + } + + _compileBinaryNode(node) { + const { op, first, second } = node; + if (op === TOKEN.bitshift) { + return this._compileBitshiftNode(first, second); + } + + if (op === TOKEN.div) { + return this._compileSafeDivNode(first, second); + } + + if (op === TOKEN.idiv) { + return this._compileSafeIdivNode(first, second); + } + + if (op === TOKEN.mod) { + return this._compileModNode(first, second); + } + + if (op === TOKEN.atan) { + return this._compileAtanNode(first, second); + } + + if (op === TOKEN.and || op === TOKEN.or || op === TOKEN.xor) { + return this._compileBitwiseNode(op, first, second); + } + + return this._compileStandardBinaryNode(op, first, second); + } + + /** + * Compile `node` leaving an i32 (0 or 1) on the stack, short-circuiting + * f64/i32 round-trips for comparisons, boolean and/or/xor, and boolean `not`. + */ + _compileNodeAsBoolI32(node) { + if (node.type === PS_NODE.binary) { + // Comparison: leaves i32 directly. + const wasmOp = PsWasmCompiler.#comparisonToOp.get(node.op); + if (wasmOp !== undefined) { + if (!this._compileNode(node.second)) { + return false; + } + if (!this._compileNode(node.first)) { + return false; + } + this._code.push(wasmOp); + return true; + } + // Boolean and/or/xor: compile as i32, skipping f64.convert_i32_s. + if ( + node.valueType === PS_VALUE_TYPE.boolean && + (node.op === TOKEN.and || node.op === TOKEN.or || node.op === TOKEN.xor) + ) { + if (!this._compileNodeAsBoolI32(node.second)) { + return false; + } + if (!this._compileNodeAsBoolI32(node.first)) { + return false; + } + switch (node.op) { + case TOKEN.and: + this._code.push(OP.i32_and); + break; + case TOKEN.or: + this._code.push(OP.i32_or); + break; + case TOKEN.xor: + this._code.push(OP.i32_xor); + break; + } + return true; + } + } + // Boolean not: i32.eqz. + if ( + node.type === PS_NODE.unary && + node.op === TOKEN.not && + node.valueType === PS_VALUE_TYPE.boolean + ) { + if (!this._compileNodeAsBoolI32(node.operand)) { + return false; + } + this._code.push(OP.i32_eqz); + return true; + } + // Fallback: f64 then truncate (safe — boolean f64 is always 0.0 or 1.0). + if (!this._compileNode(node)) { + return false; + } + if (node.valueType === PS_VALUE_TYPE.boolean) { + this._code.push(OP.i32_trunc_f64_s); + } else { + // Unknown type: f64.ne treats NaN as truthy (NaN != 0 → 1). + this._emitF64Const(0); + this._code.push(OP.f64_ne); + } + return true; + } + + // Value-returning if/else/end; both branches leave one f64. + _compileTernaryNode(node) { + if (!this._compileNodeAsBoolI32(node.cond)) { + return false; + } + this._code.push(OP.if, F64); + if (!this._compileNode(node.then)) { + return false; + } + this._code.push(OP.else); + if (!this._compileNode(node.otherwise)) { + return false; + } + this._code.push(OP.end); + return true; + } + + /** + * Convert the parser AST to a tree, compile each output expression, clamp + * results to the declared range, store to linear memory, and assemble the + * Wasm binary. + * + * @param {import("./ast.js").PsProgram} program + * @returns {Uint8Array|null} Wasm binary, or null if compilation failed. + */ + compile(program) { + const outputs = new PSStackToTree().evaluate(program, this._nIn); + if (!outputs || outputs.length < this._nOut) { + return null; + } + + // For each output: push memory offset, compile, clamp to [min, max], store. + const code = this._code; + for (let i = 0; i < this._nOut; i++) { + const min = this._range[i * 2]; + const max = this._range[i * 2 + 1]; + code.push(OP.i32_const); + this._emitULEB128(i * 8); + if (!this._compileNode(outputs[i])) { + return null; + } + this._emitF64Const(max); + code.push(OP.f64_min); + this._emitF64Const(min); + code.push(OP.f64_max, OP.f64_store, 0x03, 0x00); + } + code.push(OP.end); // end of function body + + // Assemble the Wasm module binary + + const nIn = this._nIn; + const nLocals = this._nextLocal - nIn; + + // Type section: function type varies per compilation; imports precomputed. + const paramTypes = Array(nIn).fill(F64); + const resultTypes = []; // void: outputs are written to linear memory + const funcType = [FUNC_TYPE, ...vec(paramTypes), ...vec(resultTypes)]; + const typeSectionBytes = new Uint8Array( + section( + SECTION.type, + vec([funcType, ...PsWasmCompiler.#importTypeEntries]) + ) + ); + + // Code section: local declarations + compiled body bytes. + const localDecls = + nLocals > 0 + ? vec([[...unsignedLEB128(nLocals), F64]]) // one group of nLocals f64s + : vec([]); + const funcBodyLen = localDecls.length + code.length; + const codeSectionBytes = new Uint8Array( + section( + SECTION.code, + vec([[...unsignedLEB128(funcBodyLen), ...localDecls, ...code]]) + ) + ); + + // Section order per spec: type, import, function, memory, export, code. + const magicVersion = PsWasmCompiler.#wasmMagicVersion; + const importSection = PsWasmCompiler.#importSection; + const functionSection = PsWasmCompiler.#functionSection; + const memorySection = PsWasmCompiler.#memorySection; + const exportSection = PsWasmCompiler.#exportSection; + const totalLen = + magicVersion.length + + typeSectionBytes.length + + importSection.length + + functionSection.length + + memorySection.length + + exportSection.length + + codeSectionBytes.length; + const result = new Uint8Array(totalLen); + let off = 0; + result.set(magicVersion, off); + off += magicVersion.length; + result.set(typeSectionBytes, off); + off += typeSectionBytes.length; + result.set(importSection, off); + off += importSection.length; + result.set(functionSection, off); + off += functionSection.length; + result.set(memorySection, off); + off += memorySection.length; + result.set(exportSection, off); + off += exportSection.length; + result.set(codeSectionBytes, off); + return result; + } +} + +/** + * Parse and compile a PostScript Type 4 function source string into a Wasm + * binary. PSStackToTree handles constant folding and algebraic simplifications + * during the parse-to-tree conversion, so no separate optimizer pass is needed. + * + * @param {string} source – raw PostScript source (decoded PDF stream) + * @param {number[]} domain – flat [min0,max0, min1,max1, ...] array + * @param {number[]} range – flat [min0,max0, min1,max1, ...] array + * @returns {Uint8Array|null} – Wasm binary, or null if compilation failed + */ +function compilePostScriptToWasm(source, domain, range) { + return new PsWasmCompiler(domain, range).compile( + parsePostScriptFunction(source) + ); +} + +/** + * Build a JS wrapper around a compiled Wasm instance. + * + * The returned function has the signature `(src, srcOffset, dest, destOffset)`. + * It reads nIn f64 inputs from `src` starting at `srcOffset`, and writes + * nOut clamped f64 outputs to `dest` starting at `destOffset`. + */ +function _makeWrapper(exports, nIn, nOut) { + const { fn, mem } = exports; + const outView = new Float64Array(mem.buffer, 0, nOut); + + // Unrolled for common arities (1-4) to avoid loop overhead. + let writeOut; + switch (nOut) { + case 1: + writeOut = (dest, destOffset) => { + dest[destOffset] = outView[0]; + }; + break; + case 2: + writeOut = (dest, destOffset) => { + dest[destOffset] = outView[0]; + dest[destOffset + 1] = outView[1]; + }; + break; + case 3: + writeOut = (dest, destOffset) => { + dest[destOffset] = outView[0]; + dest[destOffset + 1] = outView[1]; + dest[destOffset + 2] = outView[2]; + }; + break; + case 4: + writeOut = (dest, destOffset) => { + dest[destOffset] = outView[0]; + dest[destOffset + 1] = outView[1]; + dest[destOffset + 2] = outView[2]; + dest[destOffset + 3] = outView[3]; + }; + break; + default: + writeOut = (dest, destOffset) => { + for (let i = 0; i < nOut; i++) { + dest[destOffset + i] = outView[i]; + } + }; + } + + // Specialize the call site for each arity so that the engine sees a + // fixed-argument call rather than a spread — avoiding the per-call + // argument-array allocation that `fn(...inBuf)` would cause. + switch (nIn) { + case 1: + return (src, srcOffset, dest, destOffset) => { + fn(src[srcOffset]); + writeOut(dest, destOffset); + }; + case 2: + return (src, srcOffset, dest, destOffset) => { + fn(src[srcOffset], src[srcOffset + 1]); + writeOut(dest, destOffset); + }; + case 3: + return (src, srcOffset, dest, destOffset) => { + fn(src[srcOffset], src[srcOffset + 1], src[srcOffset + 2]); + writeOut(dest, destOffset); + }; + case 4: + return (src, srcOffset, dest, destOffset) => { + fn( + src[srcOffset], + src[srcOffset + 1], + src[srcOffset + 2], + src[srcOffset + 3] + ); + writeOut(dest, destOffset); + }; + default: { + // Fallback for unusual arities: pre-allocate once, copy per call. + const inBuf = new Float64Array(nIn); + return (src, srcOffset, dest, destOffset) => { + for (let i = 0; i < nIn; i++) { + inBuf[i] = src[srcOffset + i]; + } + fn(...inBuf); + writeOut(dest, destOffset); + }; + } + } +} + +/** + * Parse, optimize, compile, and synchronously instantiate a PostScript Type 4 + * function source string as a callable JavaScript function backed by a Wasm + * module. + * + * Note: synchronous Wasm compilation is only allowed for small modules + * (< 4 KB in most browsers). Type 4 functions always qualify. + * + * @param {string} source – raw PostScript source (decoded PDF stream) + * @param {number[]} domain – flat [min0,max0, min1,max1, ...] array + * @param {number[]} range – flat [min0,max0, min1,max1, ...] array + * @returns {Function|null} – a `(src, srcOffset, dest, destOffset)` function + * that writes nOut clamped f64 outputs to `dest`, or null if compilation + * failed. + */ +function buildPostScriptWasmFunction(source, domain, range) { + const bytes = compilePostScriptToWasm(source, domain, range); + if (!bytes) { + return null; + } + try { + const instance = new WebAssembly.Instance( + new WebAssembly.Module(bytes), + _mathImportObject + ); + return _makeWrapper( + instance.exports, + domain.length >> 1, + range.length >> 1 + ); + } catch { + return null; + } +} + +export { buildPostScriptWasmFunction, compilePostScriptToWasm }; diff --git a/test/unit/clitests.json b/test/unit/clitests.json index c1ca99623..461185d9d 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -44,6 +44,7 @@ "pdf_spec.js", "pdf_viewer.component_spec.js", "pdf_viewer_spec.js", + "postscript_spec.js", "primitives_spec.js", "stream_spec.js", "struct_tree_spec.js", diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index f79702bc9..7c8b2563e 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -87,6 +87,7 @@ async function initializePDFJS(callback) { "pdfjs-test/unit/pdf_spec.js", "pdfjs-test/unit/pdf_viewer.component_spec.js", "pdfjs-test/unit/pdf_viewer_spec.js", + "pdfjs-test/unit/postscript_spec.js", "pdfjs-test/unit/primitives_spec.js", "pdfjs-test/unit/scripting_spec.js", "pdfjs-test/unit/stream_spec.js", diff --git a/test/unit/postscript_spec.js b/test/unit/postscript_spec.js new file mode 100644 index 000000000..ee050125e --- /dev/null +++ b/test/unit/postscript_spec.js @@ -0,0 +1,1847 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + buildPostScriptWasmFunction, + compilePostScriptToWasm, +} from "../../src/core/postscript/wasm_compiler.js"; +import { Lexer, TOKEN } from "../../src/core/postscript/lexer.js"; +import { + parsePostScriptFunction, + Parser, + PS_VALUE_TYPE, + PsArgNode, + PsBinaryNode, + PsBlock, + PsConstNode, + PsIf, + PsIfElse, + PsNumber, + PsOperator, + PsProgram, + PSStackToTree, + PsTernaryNode, + PsUnaryNode, +} from "../../src/core/postscript/ast.js"; + +// Precision argument for toBeCloseTo() in trigonometric tests. +const TRIGONOMETRY_EPS = 1e-10; + +describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () { + // Lexer + describe("PostScript Type 4 lexer", function () { + /** Tokenize a string and return the sequence of token ids. */ + function tokenIds(src) { + const lexer = new Lexer(src); + const ids = []; + let tok; + while ((tok = lexer.next()).id !== TOKEN.eof) { + ids.push(tok.id); + } + return ids; + } + + it("tokenizes numbers", function () { + const lexer = new Lexer("3 -1.5 +0.5 .25 1.5e3"); + const values = []; + let tok; + while ((tok = lexer.next()).id !== TOKEN.eof) { + values.push(tok.value); + } + expect(values).toEqual([3, -1.5, 0.5, 0.25, 1500]); + }); + + it("tokenizes braces", function () { + expect(tokenIds("{ }")).toEqual([TOKEN.lbrace, TOKEN.rbrace]); + }); + + it("tokenizes all operator keywords", function () { + const ops = [ + "abs add and atan bitshift ceiling copy cos cvi cvr div dup eq exch", + "exp false floor ge gt idiv if ifelse index le ln log lt mod mul ne", + "neg not or pop roll round sin sqrt sub true truncate xor", + ].join(" "); + const ids = tokenIds(ops); + // Every id should be a valid non-structural, non-number token. + for (const id of ids) { + expect(id).toBeGreaterThan(TOKEN.rbrace); + expect(id).toBeLessThan(TOKEN.eof); + } + }); + + it("skips % comments", function () { + expect(tokenIds("{ % comment\nadd }")).toEqual([ + TOKEN.lbrace, + TOKEN.add, + TOKEN.rbrace, + ]); + }); + + it("skips whitespace", function () { + expect(tokenIds(" \t\n\r\fadd")).toEqual([TOKEN.add]); + }); + + it("operator tokens carry their name as value", function () { + const lexer = new Lexer("mul"); + const tok = lexer.next(); + expect(tok.id).toBe(TOKEN.mul); + expect(tok.value).toBe("mul"); + }); + + it("reuses singleton operator tokens", function () { + const lexer1 = new Lexer("add"); + const lexer2 = new Lexer("add"); + expect(lexer1.next()).toBe(lexer2.next()); + }); + + it("returns number(0) for unknown operator", function () { + const tok = new Lexer("foo").next(); + expect(tok.id).toBe(TOKEN.number); + expect(tok.value).toBe(0); + }); + + it("returns number(0) for non-finite number (e.g. 1e999 → Infinity)", function () { + const tok = new Lexer("1e999").next(); + expect(tok.id).toBe(TOKEN.number); + expect(tok.value).toBe(0); + }); + + it("returns number(0) for unexpected character", function () { + const tok = new Lexer("@").next(); + expect(tok.id).toBe(TOKEN.number); + expect(tok.value).toBe(0); + }); + }); + + // Parser + describe("PostScript Type 4 parser", function () { + it("parses an empty program", function () { + const prog = parsePostScriptFunction("{ }"); + expect(prog).toBeInstanceOf(PsProgram); + expect(prog.body).toBeInstanceOf(PsBlock); + expect(prog.body.instructions.length).toBe(0); + }); + + it("parses number literals", function () { + const prog = parsePostScriptFunction("{ 1 2.5 -3 }"); + const instrs = prog.body.instructions; + expect(instrs.length).toBe(3); + expect(instrs[0]).toBeInstanceOf(PsNumber); + expect(instrs[0].value).toBe(1); + expect(instrs[1].value).toBeCloseTo(2.5); + expect(instrs[2].value).toBe(-3); + }); + + it("parses operators", function () { + const prog = parsePostScriptFunction("{ add mul sub }"); + const instrs = prog.body.instructions; + expect(instrs.every(n => n instanceof PsOperator)).toBeTrue(); + expect(instrs.map(n => n.op)).toEqual([TOKEN.add, TOKEN.mul, TOKEN.sub]); + }); + + it("parses { } if", function () { + const prog = parsePostScriptFunction("{ 0.5 gt { pop 1 } if }"); + const ifNode = prog.body.instructions.at(-1); + expect(ifNode).toBeInstanceOf(PsIf); + expect(ifNode.then).toBeInstanceOf(PsBlock); + }); + + it("parses { } { } ifelse", function () { + const prog = parsePostScriptFunction( + "{ 0.5 gt { 2 mul } { 0.5 mul } ifelse }" + ); + const ifelse = prog.body.instructions.at(-1); + expect(ifelse).toBeInstanceOf(PsIfElse); + expect(ifelse.then.instructions[0].value).toBeCloseTo(2); + expect(ifelse.otherwise.instructions[0].value).toBeCloseTo(0.5); + }); + + it("throws on standalone if without preceding block", function () { + const parser = new Parser(new Lexer("{ 1 if }")); + expect(() => parser.parse()).toThrow(); + }); + + it("ignores content after closing brace (warns, does not throw)", function () { + const parser = new Parser(new Lexer("{ add } add")); + expect(() => parser.parse()).not.toThrow(); + }); + + it("throws when first token is not a left brace", function () { + expect(() => parsePostScriptFunction("add }")).toThrow(); + }); + + it("throws when a procedure block is not followed by if or ifelse", function () { + expect(() => parsePostScriptFunction("{ { 1 } add }")).toThrow(); + }); + }); + + // Wasm compiler. + describe("PostScript Type 4 Wasm compiler", function () { + /** + * Compile and instantiate a PostScript Type 4 function, then call it. + * Returns null if compilation returns null (unsupported program). + * For single-output functions returns a scalar; for multi-output an Array. + */ + function compileAndRun(src, domain, range, args) { + const fn = buildPostScriptWasmFunction(src, domain, range); + if (!fn) { + return null; + } + const nOut = range.length >> 1; + const srcBuf = new Float64Array(args); + const dest = new Float64Array(nOut); + fn(srcBuf, 0, dest, 0); + return nOut === 1 ? dest[0] : Array.from(dest); + } + + function readULEB128(bytes, offset) { + let value = 0; + let shift = 0; + let pos = offset; + while (true) { + const byte = bytes[pos++]; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return { value, offset: pos }; + } + shift += 7; + } + } + + function getWasmLocalCount(bytes) { + let offset = 8; // magic + version + while (offset < bytes.length) { + const sectionId = bytes[offset++]; + const size = readULEB128(bytes, offset); + offset = size.offset; + const sectionStart = offset; + const sectionEnd = sectionStart + size.value; + + if (sectionId === 0x0a) { + const fnCount = readULEB128(bytes, offset); + expect(fnCount.value).toBe(1); + offset = fnCount.offset; + + const bodySize = readULEB128(bytes, offset); + offset = bodySize.offset; + + const localDeclCount = readULEB128(bytes, offset); + offset = localDeclCount.offset; + + let totalLocals = 0; + for (let i = 0; i < localDeclCount.value; i++) { + const count = readULEB128(bytes, offset); + offset = count.offset + 1; // skip value type + totalLocals += count.value; + } + return totalLocals; + } + + offset = sectionEnd; + } + throw new Error("Wasm code section not found."); + } + + // Arithmetic. + + it("compiles add", async function () { + const r = await compileAndRun( + "{ add }", + [0, 1, 0, 1], + [0, 2], + [0.3, 0.7] + ); + expect(r).toBeCloseTo(1.0, 9); + }); + + it("compiles sub", async function () { + const r = await compileAndRun( + "{ sub }", + [0, 1, 0, 1], + [0, 1], + [0.8, 0.3] + ); + expect(r).toBeCloseTo(0.5, 9); + }); + + it("compiles mul", async function () { + const r = await compileAndRun("{ 0.5 mul }", [0, 1], [0, 1], [0.4]); + expect(r).toBeCloseTo(0.2, 9); + }); + + it("compiles div", async function () { + const r = await compileAndRun("{ div }", [0, 10, 0, 10], [0, 10], [6, 3]); + expect(r).toBeCloseTo(2, 9); + }); + + it("div by zero returns 0", async function () { + const r = await compileAndRun("{ div }", [0, 10, 0, 10], [0, 10], [5, 0]); + expect(r).toBe(0); + }); + + it("compiles idiv", async function () { + const r = await compileAndRun( + "{ idiv }", + [0, 10, 1, 10], + [0, 10], + [7, 2] + ); + expect(r).toBeCloseTo(3, 9); + }); + + it("idiv by zero returns 0", async function () { + const r = await compileAndRun( + "{ idiv }", + [0, 10, 0, 10], + [0, 10], + [5, 0] + ); + expect(r).toBe(0); + }); + + it("compiles mod", async function () { + const r = await compileAndRun("{ mod }", [0, 10, 1, 10], [0, 10], [7, 3]); + expect(r).toBeCloseTo(1, 9); + }); + + it("mod by zero returns 0", async function () { + const r = await compileAndRun("{ mod }", [0, 10, 0, 10], [0, 10], [5, 0]); + expect(r).toBe(0); + }); + + it("compiles mod with constant divisor", async function () { + // { 3 mod } — divisor is a compile-time constant, exercises the + // constant-divisor branch in _compileModNode. + const r = await compileAndRun("{ 3 mod }", [0, 10], [0, 3], [7]); + expect(r).toBeCloseTo(1, 9); // 7 mod 3 = 1 + }); + + it("compiles integer xor (bitwise)", async function () { + // { 5 xor } with an integer-typed arg — exercises the non-boolean path in + // _compileBitwiseOperandI32 and the xor case in _compileBitwiseNode. + const r = await compileAndRun("{ 5 xor }", [-128, 127], [-128, 127], [3]); + expect(r).toBeCloseTo(6, 9); // 3 XOR 5 = 6 + }); + + it("compiles neg", async function () { + // neg applied to a variable — the optimizer cannot fold this. + // abs(neg(x)) is optimized to abs(x), so test neg alone. + const r = await compileAndRun("{ neg }", [-1, 1], [-1, 1], [-0.5]); + expect(r).toBeCloseTo(0.5, 9); + }); + + it("compiles neg and abs", async function () { + const r = await compileAndRun("{ neg abs }", [-1, 1], [0, 1], [-0.8]); + expect(r).toBeCloseTo(0.8, 9); + }); + + it("compiles cvi (truncate to integer)", async function () { + const r = await compileAndRun("{ 1.7 add cvi }", [0, 2], [0, 4], [0.5]); + expect(r).toBeCloseTo(2, 9); // trunc(0.5 + 1.7) = trunc(2.2) = 2 + }); + + it("compiles cvr (identity on reals)", async function () { + const r = await compileAndRun("{ cvr }", [0, 1], [0, 1], [0.7]); + expect(r).toBeCloseTo(0.7, 9); + }); + + // Math. + + it("compiles sqrt", async function () { + const r = await compileAndRun("{ sqrt }", [0, 100], [0, 10], [9]); + expect(r).toBeCloseTo(3, 9); + }); + + it("compiles floor", async function () { + const r = await compileAndRun("{ floor }", [-2, 2], [-2, 2], [1.7]); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles ceiling", async function () { + const r = await compileAndRun("{ ceiling }", [-2, 2], [-2, 2], [1.2]); + expect(r).toBeCloseTo(2, 9); + }); + + it("compiles round", async function () { + const r = await compileAndRun("{ round }", [-2, 2], [-2, 2], [1.5]); + expect(r).toBeCloseTo(2, 9); + }); + + it("round uses round-half-up (0.5 rounds to 1, -0.5 rounds to 0)", async function () { + const r1 = await compileAndRun("{ round }", [-2, 2], [-2, 2], [0.5]); + expect(r1).toBe(1); + const r2 = await compileAndRun("{ round }", [-2, 2], [-2, 2], [-0.5]); + expect(r2).toBe(0); + }); + + it("compiles truncate", async function () { + const r = await compileAndRun("{ truncate }", [-2, 2], [-2, 2], [-1.9]); + expect(r).toBeCloseTo(-1, 9); + }); + + it("compiles ln", async function () { + const r = await compileAndRun("{ ln }", [0.001, 10], [-10, 10], [Math.E]); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles log (base 10)", async function () { + const r = await compileAndRun("{ log }", [0.001, 1000], [-3, 3], [100]); + expect(r).toBeCloseTo(2, 9); + }); + + it("compiles exp (base ^ exponent)", async function () { + const r = await compileAndRun( + "{ exp }", + [0, 10, 0, 10], + [0, 2000], + [2, 10] + ); + expect(r).toBeCloseTo(1024, 6); + }); + + it("compiles x ^ -1 → 1/x (strength reduction)", async function () { + const r = await compileAndRun("{ -1 exp }", [0.1, 10], [0.1, 10], [2]); + expect(r).toBeCloseTo(0.5, 9); // 1/2 = 0.5 + }); + + it("compiles x ^ 3 → (x*x)*x (strength reduction)", async function () { + const r = await compileAndRun("{ 3 exp }", [0, 10], [0, 1000], [2]); + expect(r).toBeCloseTo(8, 9); // 2^3 = 8 + }); + + it("compiles x ^ 4 → (x*x)*(x*x) (strength reduction)", async function () { + // x^4 uses CSE: x*x is computed once and squared — exercises the + // local_tee/local_get path in _compileStandardBinaryNode. + const r = await compileAndRun("{ 4 exp }", [0, 10], [0, 10000], [2]); + expect(r).toBeCloseTo(16, 9); // 2^4 = 16 + }); + + // Trigonometry (degrees). + + it("compiles sin (degrees)", async function () { + const r = await compileAndRun("{ sin }", [-360, 360], [-1, 1], [90]); + expect(r).toBeCloseTo(1, TRIGONOMETRY_EPS); + }); + + it("compiles cos (degrees)", async function () { + const r = await compileAndRun("{ cos }", [-360, 360], [-1, 1], [0]); + expect(r).toBeCloseTo(1, TRIGONOMETRY_EPS); + }); + + it("sin(360) = 0 — boundary normalizes mod 360", function () { + const r = compileAndRun("{ sin }", [0, 360], [-1, 1], [360]); + expect(r).toBe(0); + }); + + it("cos(360) = 1 — boundary normalizes mod 360", function () { + const r = compileAndRun("{ cos }", [0, 360], [-1, 1], [360]); + expect(r).toBe(1); + }); + + it("compiles atan (degrees, result in [0,360))", async function () { + // atan(1, 1) = 45° + const r = await compileAndRun( + "{ atan }", + [-10, 10, -10, 10], + [0, 360], + [1, 1] + ); + expect(r).toBeCloseTo(45, 6); + }); + + it("atan normalizes negative angles to [0,360)", async function () { + // atan(-1, 1) would be -45°, should become 315° + const r = await compileAndRun( + "{ atan }", + [-10, 10, -10, 10], + [0, 360], + [-1, 1] + ); + expect(r).toBeCloseTo(315, 6); + }); + + // Stack operators. + + it("compiles dup", async function () { + const r = await compileAndRun("{ dup mul }", [0, 1], [0, 1], [0.5]); + expect(r).toBeCloseTo(0.25, 9); + }); + + it("compiles exch", async function () { + const r = await compileAndRun( + "{ exch div }", + [0, 10, 0, 10], + [0, 10], + [1, 2] + ); + expect(r).toBeCloseTo(2, 9); // 2 / 1 + }); + + it("compiles pop", async function () { + const r = await compileAndRun( + "{ pop }", + [0, 1, 0, 1], + [0, 1], + [0.3, 0.7] + ); + expect(r).toBeCloseTo(0.3, 9); // 0.7 popped, 0.3 remains + }); + + it("compiles copy", async function () { + // { 1 copy add }: one input a → stack [a, a] → add → [2a] + const r = await compileAndRun("{ 1 copy add }", [0, 1], [0, 2], [0.4]); + expect(r).toBeCloseTo(0.8, 9); + }); + + it("compiles index", function () { + // { 1 index add }: inputs (a, b) → [a, b, a] → add → [a, a+b] + const fn = buildPostScriptWasmFunction( + "{ 1 index add }", + [0, 1, 0, 1], + [0, 1, 0, 2] + ); + const src = new Float64Array([0.3, 0.7]); + const dest = new Float64Array(2); + fn(src, 0, dest, 0); + expect(dest[0]).toBeCloseTo(0.3, 9); // a unchanged + expect(dest[1]).toBeCloseTo(1.0, 9); // a + b + }); + + it("compiles roll", function () { + // { 2 1 roll }: inputs (a, b) → positive j moves bottom to top → (b, a) + const fn = buildPostScriptWasmFunction( + "{ 2 1 roll }", + [0, 1, 0, 1], + [0, 1, 0, 1] + ); + const src = new Float64Array([0.3, 0.7]); + const dest = new Float64Array(2); + fn(src, 0, dest, 0); + expect(dest[0]).toBeCloseTo(0.7, 9); // b is now at bottom + expect(dest[1]).toBeCloseTo(0.3, 9); // a is now on top + }); + + it("compiles roll with more than two values", function () { + const fn = buildPostScriptWasmFunction( + "{ 3 1 roll }", + [0, 1, 0, 1, 0, 1], + [0, 1, 0, 1, 0, 1] + ); + const src = new Float64Array([0.1, 0.2, 0.3]); + const dest = new Float64Array(3); + fn(src, 0, dest, 0); + expect(dest[0]).toBeCloseTo(0.3, 9); + expect(dest[1]).toBeCloseTo(0.1, 9); + expect(dest[2]).toBeCloseTo(0.2, 9); + }); + + // Multiple inputs / outputs — exercises _makeWrapper specializations. + + it("compiles 3-output function", function () { + const fn = buildPostScriptWasmFunction( + "{ dup dup }", + [0, 1], + [0, 1, 0, 1, 0, 1] + ); + const src = new Float64Array([0.5]); + const dest = new Float64Array(3); + fn(src, 0, dest, 0); + expect(dest[0]).toBeCloseTo(0.5, 9); + expect(dest[1]).toBeCloseTo(0.5, 9); + expect(dest[2]).toBeCloseTo(0.5, 9); + }); + + it("compiles 4-output function", function () { + const fn = buildPostScriptWasmFunction( + "{ dup dup dup }", + [0, 1], + [0, 1, 0, 1, 0, 1, 0, 1] + ); + const src = new Float64Array([0.5]); + const dest = new Float64Array(4); + fn(src, 0, dest, 0); + for (let i = 0; i < 4; i++) { + expect(dest[i]).toBeCloseTo(0.5, 9); + } + }); + + it("compiles 5-output function (default writer path)", function () { + const fn = buildPostScriptWasmFunction( + "{ dup dup dup dup }", + [0, 1], + [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] + ); + const src = new Float64Array([0.5]); + const dest = new Float64Array(5); + fn(src, 0, dest, 0); + for (let i = 0; i < 5; i++) { + expect(dest[i]).toBeCloseTo(0.5, 9); + } + }); + + it("compiles 3-input function", async function () { + const r = await compileAndRun( + "{ add add }", + [0, 1, 0, 1, 0, 1], + [0, 3], + [0.3, 0.3, 0.4] + ); + expect(r).toBeCloseTo(1.0, 9); + }); + + it("compiles 4-input function", async function () { + const r = await compileAndRun( + "{ add add add }", + [0, 1, 0, 1, 0, 1, 0, 1], + [0, 4], + [0.25, 0.25, 0.25, 0.25] + ); + expect(r).toBeCloseTo(1.0, 9); + }); + + it("compiles 5-input function (default caller path)", async function () { + const r = await compileAndRun( + "{ add add add add }", + [0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + [0, 5], + [0.2, 0.2, 0.2, 0.2, 0.2] + ); + expect(r).toBeCloseTo(1.0, 9); + }); + + // Comparison / boolean. + + it("compiles eq", async function () { + const r = await compileAndRun("{ eq }", [0, 1, 0, 1], [0, 1], [0.5, 0.5]); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles ne (not-equal)", async function () { + const r = await compileAndRun("{ 0.5 ne }", [0, 1], [0, 1], [0.3]); + expect(r).toBeCloseTo(1, 9); // 0.3 ≠ 0.5 → true → 1 + }); + + it("compiles lt (less-than)", async function () { + const r = await compileAndRun("{ 0.5 lt }", [0, 1], [0, 1], [0.3]); + expect(r).toBeCloseTo(1, 9); // 0.3 < 0.5 → true → 1 + }); + + it("compiles ge (greater-or-equal)", async function () { + const r = await compileAndRun("{ 0.5 ge }", [0, 1], [0, 1], [0.7]); + expect(r).toBeCloseTo(1, 9); // 0.7 ≥ 0.5 → true → 1 + }); + + it("compiles gt", async function () { + const r = await compileAndRun("{ gt }", [0, 1, 0, 1], [0, 1], [0.8, 0.3]); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles le", async function () { + const r = await compileAndRun("{ le }", [0, 1, 0, 1], [0, 1], [0.3, 0.8]); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles true and false literals", async function () { + const t = await compileAndRun("{ true }", [], [0, 1], []); + const f = await compileAndRun("{ false }", [], [0, 1], []); + expect(t).toBeCloseTo(1, 9); + expect(f).toBeCloseTo(0, 9); + }); + + // Conditionals. + + it("compiles ifelse — true branch taken", async function () { + const r = await compileAndRun( + "{ dup 0.5 gt { 2 mul } { 0.5 mul } ifelse }", + [0, 1], + [0, 2], + [0.8] + ); + expect(r).toBeCloseTo(1.6, 9); + }); + + it("compiles ifelse — false branch taken", async function () { + const r = await compileAndRun( + "{ dup 0.5 gt { 2 mul } { 0.5 mul } ifelse }", + [0, 1], + [0, 2], + [0.2] + ); + expect(r).toBeCloseTo(0.1, 9); + }); + + it("compiles if — condition true", async function () { + // { dup 1 gt { pop 1 } if } — clamp x to 1 from above + const r = await compileAndRun( + "{ dup 1 gt { pop 1 } if }", + [0, 2], + [0, 2], + [1.5] + ); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles if — condition false", async function () { + const r = await compileAndRun( + "{ dup 1 gt { pop 1 } if }", + [0, 2], + [0, 2], + [0.5] + ); + expect(r).toBeCloseTo(0.5, 9); + }); + + it("compiles stack-growing if — early-exit guard, guard fires", function () { + // { dup 0 le { pop 0.2 0.8 0 } if 0 gt { 0.3 0.7 } if } + // in0 = -0.5 (≤ 0): early-exit guard fires → outputs (0.2, 0.8) + const r = compileAndRun( + "{ dup 0 le { pop 0.2 0.8 0 } if 0 gt { 0.3 0.7 } if }", + [-1, 1], + [0, 1, 0, 1], + [-0.5] + ); + expect(r[0]).toBeCloseTo(0.2, 9); + expect(r[1]).toBeCloseTo(0.8, 9); + }); + + it("compiles stack-growing if — early-exit guard, guard does not fire", function () { + // { dup 0 le { pop 0.2 0.8 0 } if 0 gt { 0.3 0.7 } if } + // in0 = 0.5 (> 0): guard doesn't fire; final default fires → (0.3, 0.7) + const r = compileAndRun( + "{ dup 0 le { pop 0.2 0.8 0 } if 0 gt { 0.3 0.7 } if }", + [-1, 1], + [0, 1, 0, 1], + [0.5] + ); + expect(r[0]).toBeCloseTo(0.3, 9); + expect(r[1]).toBeCloseTo(0.7, 9); + }); + + // Range clamping. + + it("clamps output to declared range", async function () { + // mul exceeds range [0, 0.5] → result clamped + const r = await compileAndRun( + "{ add }", + [0, 1, 0, 1], + [0, 0.5], + [0.4, 0.4] + ); + expect(r).toBeCloseTo(0.5, 9); + }); + + // Bitwise. + + it("compiles bitshift left (literal shift)", async function () { + const r = await compileAndRun("{ 3 bitshift }", [0, 256], [0, 256], [1]); + expect(r).toBeCloseTo(8, 9); // 1 << 3 + }); + + it("compiles bitshift right (negative literal shift)", async function () { + const r = await compileAndRun( + "{ -2 bitshift }", + [-256, 256], + [-256, 256], + [8] + ); + expect(r).toBeCloseTo(2, 9); // 8 >> 2 + }); + + it("compiles large shift amount (exercises multi-byte LEB128 in _emitULEB128)", async function () { + // Shift amount 128 encodes as two LEB128 bytes — exercises the + // b |= 0x80 branch in _emitULEB128. + // Wasm i32.shl uses shift % 32, so 128 % 32 = 0 → + // left-shift by 0 = identity. + const r = await compileAndRun( + "{ 128 bitshift }", + [-1000, 1000], + [-1000, 1000], + [1] + ); + expect(r).toBeCloseTo(1, 9); + }); + + it("compiles long function body (exercises multi-byte LEB128 in unsignedLEB128)", async function () { + // 13 repeated { 1 add } iterations produce a code body > 127 bytes, + // causing funcBodyLen to require a two-byte LEB128 encoding and + // exercising the byte |= 0x80 branch in unsignedLEB128. + const src = + "{ 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add 1 add }"; + const r = await compileAndRun(src, [0, 1], [0, 14], [0]); + expect(r).toBeCloseTo(13, 9); + }); + + // Returns null for unsupported ops. + + it("returns null for programs with roll (non-literal args)", function () { + const fn = buildPostScriptWasmFunction( + "{ roll }", + [0, 1, 0, 1], + [0, 1, 0, 1] + ); + expect(fn).toBeNull(); + }); + + it("returns null from the sync builder when compilation fails", function () { + const fn = buildPostScriptWasmFunction( + "{ roll }", + [0, 1, 0, 1], + [0, 1, 0, 1] + ); + expect(fn).toBeNull(); + }); + + // not — boolean vs integer. + + it("compiles boolean not (logical NOT)", async function () { + // 0.5 0.5 eq → true (1.0); not → false (0.0) + const r = await compileAndRun("{ dup eq not }", [0, 1], [0, 1], [0.5]); + expect(r).toBeCloseTo(0, 9); + }); + + it("compiles integer not (bitwise NOT)", async function () { + // ~5 = -6 + const r = await compileAndRun("{ not }", [-256, 256], [-256, 256], [5]); + expect(r).toBeCloseTo(-6, 9); + }); + + // _compileNodeAsBoolI32 — ternary condition optimizations. + + it("ifelse with comparison condition (true branch)", async function () { + // x > 0.5: comparison emitted directly as i32, no f64 round-trip. + const r = await compileAndRun( + "{ 0.5 gt { 1 } { 0 } ifelse }", + [0, 1], + [0, 1], + [0.7] + ); + expect(r).toBeCloseTo(1, 9); + }); + + it("ifelse with comparison condition (false branch)", async function () { + const r = await compileAndRun( + "{ 0.5 gt { 1 } { 0 } ifelse }", + [0, 1], + [0, 1], + [0.3] + ); + expect(r).toBeCloseTo(0, 9); + }); + + it("ifelse with boolean-and condition", async function () { + // not(comparison) → negated comparison is already handled by the + // optimizer; this test uses an and of two comparisons so the condition + // node is a PsBinaryNode(and) — exercises the i32_trunc_f64_s fallback + // in _compileNodeAsBoolI32. + const src = "{ dup 0.3 gt exch 0.7 lt and { 1 } { 0 } ifelse }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r0).toBeCloseTo(1, 9); // 0.5 in (0.3, 0.7) + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r1).toBeCloseTo(0, 9); // 0.2 outside range + }); + + it("ifelse with not(boolean-and) condition", async function () { + // not(and(two comparisons)) — exercises the boolean `not` path in + // _compileNodeAsBoolI32 (recursive call + i32.eqz). + const src = "{ dup 0.3 gt exch 0.7 lt and not { 1 } { 0 } ifelse }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r0).toBeCloseTo(0, 9); // 0.5 in (0.3, 0.7) → not → false + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r1).toBeCloseTo(1, 9); // 0.2 outside range → not → true + }); + + // _compileBitwiseNode boolean-operand optimizations. + + it("boolean-and of two comparisons as standalone output", async function () { + // and(x>0.3, x<0.7) used as the direct output (not as ternary condition) + // — exercises _compileBitwiseOperandI32 with boolean operands. + const src = "{ dup 0.3 gt exch 0.7 lt and }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r0).toBeCloseTo(1, 9); + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r1).toBeCloseTo(0, 9); + }); + + it("boolean-or of two comparisons as standalone output", async function () { + // or(x<0.3, x>0.7): true when x is outside [0.3, 0.7]. + const src = "{ dup 0.3 lt exch 0.7 gt or }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r0).toBeCloseTo(1, 9); + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r1).toBeCloseTo(0, 9); + }); + + it("not(boolean-and) as standalone output", async function () { + // not(and(cmp1, cmp2)): exercises _compileUnaryNode(not, boolean) using + // _compileNodeAsBoolI32 for the operand, eliminating all f64/i32 + // round-trips. + const src = "{ dup 0.3 gt exch 0.7 lt and not }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r0).toBeCloseTo(0, 9); // inside → and=true → not=false + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r1).toBeCloseTo(1, 9); // outside → and=false → not=true + }); + + it("nested ifelse with comparison conditions", async function () { + // Three-way branch: x < 0.3 → 0, x > 0.7 → 1, else 0.5. + // Each ternary condition goes through _compileNodeAsBoolI32. + const src = + "{ dup 0.3 lt { pop 0 } { dup 0.7 gt { pop 1 } { pop 0.5 } ifelse } ifelse }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.1]); + expect(r0).toBeCloseTo(0, 9); + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.9]); + expect(r1).toBeCloseTo(1, 9); + const r2 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r2).toBeCloseTo(0.5, 9); + }); + + it("ifelse with boolean-or condition", async function () { + // or(x<0.3, x>0.7) as ternary condition — exercises the TOKEN.or case + // in the boolean and/or/xor branch of _compileNodeAsBoolI32. + const src = "{ dup 0.3 lt exch 0.7 gt or { 1 } { 0 } ifelse }"; + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r0).toBeCloseTo(1, 9); // 0.2 < 0.3 → true + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.5]); + expect(r1).toBeCloseTo(0, 9); // 0.5 inside → false + }); + + it("ifelse with boolean-xor condition", async function () { + // xor(x<0.5, x>0.3) as ternary condition — exercises TOKEN.xor in + // _compileNodeAsBoolI32; true when exactly one condition holds. + const src = "{ dup 0.5 lt exch 0.3 gt xor { 1 } { 0 } ifelse }"; + // x=0.4: 0.4<0.5=true, 0.4>0.3=true → xor=false → 0 + const r0 = await compileAndRun(src, [0, 1], [0, 1], [0.4]); + expect(r0).toBeCloseTo(0, 9); + // x=0.2: 0.2<0.5=true, 0.2>0.3=false → xor=true → 1 + const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]); + expect(r1).toBeCloseTo(1, 9); + }); + + it("ifelse with numeric condition (fallback _compileNodeAsBoolI32 path)", async function () { + // The condition is the raw input arg (numeric, not boolean), so + // _compileNodeAsBoolI32 falls through to the general path and emits + // f64.const 0 / f64.ne to convert to i32. + const r0 = await compileAndRun( + "{ { 1 } { 0 } ifelse }", + [0, 1], + [0, 1], + [0.7] + ); + expect(r0).toBeCloseTo(1, 9); // 0.7 ≠ 0 → truthy → 1 + const r1 = await compileAndRun( + "{ { 1 } { 0 } ifelse }", + [0, 1], + [0, 1], + [0] + ); + expect(r1).toBeCloseTo(0, 9); // 0 → falsy → 0 + }); + + // _compileStandardBinaryNode shared-operand CSE. + + it("shared non-trivial operand uses local_tee (x+1)^2", async function () { + // (x+1)^2: the x^2→x*x strength reduction creates PsBinaryNode(mul, + // add_node, add_node) where add_node is non-trivial — exercises the + // local_tee/local_get path in _compileStandardBinaryNode. + const r = await compileAndRun("{ 1 add 2 exp }", [0, 10], [0, 100], [3]); + expect(r).toBeCloseTo(16, 9); // (3+1)^2 = 16 + }); + + it("shared non-trivial operand uses local_tee (x+2)^2 via dup", async function () { + // `2 add dup mul`: dup of the add node gives PsBinaryNode(mul, add, add) + // with the same reference twice — exercises the local_tee/local_get path. + const r = await compileAndRun( + "{ 2 add dup mul }", + [0, 10], + [0, 100], + [3] + ); + expect(r).toBeCloseTo(25, 9); // (3+2)^2 = 25 + }); + + it("compiles x^3 without changing behavior", async function () { + const r = await compileAndRun("{ 3 exp }", [0, 10], [0, 1000], [2]); + expect(r).toBeCloseTo(8, 9); + }); + + it("compiles x^4 without changing behavior", async function () { + const r = await compileAndRun("{ 4 exp }", [0, 10], [0, 10000], [2]); + expect(r).toBeCloseTo(16, 9); + }); + + it("compiles x^-1 without changing behavior", async function () { + const r = await compileAndRun("{ -1 exp }", [1, 10], [0, 1], [4]); + expect(r).toBeCloseTo(0.25, 9); + }); + + it("reuses temporary locals across sequential shared-subexpression codegen", function () { + const bytes = compilePostScriptToWasm( + "{ dup 1 add dup mul exch 2 add dup mul add }", + [0, 10], + [0, 1000] + ); + expect(bytes).not.toBeNull(); + expect(getWasmLocalCount(bytes)).toBe(1); + }); + + it("reuses temporary locals across sequential mod codegen", function () { + const bytes = compilePostScriptToWasm( + "{ 3 mod exch 5 mod add }", + [0, 10, 0, 10], + [0, 100] + ); + expect(bytes).not.toBeNull(); + expect(getWasmLocalCount(bytes)).toBe(1); + }); + + it("reuses temporary locals across sequential atan codegen", function () { + const bytes = compilePostScriptToWasm( + "{ atan 3 1 roll atan }", + [0, 10, 0, 10, 0, 10, 0, 10], + [0, 360, 0, 360] + ); + expect(bytes).not.toBeNull(); + expect(getWasmLocalCount(bytes)).toBe(1); + }); + + // min/max fold and related runtime tests. + + it("compiles x^0.25 → sqrt(sqrt(x))", async function () { + const r = await compileAndRun("{ 0.25 exp }", [0, 16], [0, 2], [16]); + expect(r).toBeCloseTo(2, 9); // 16^0.25 = 2 + }); + + it("compiles neg(a − b) → b − a", async function () { + // neg(x - 3) = 3 - x; at x=1 → 2 + const r = await compileAndRun("{ 3 sub neg }", [0, 10], [0, 10], [1]); + expect(r).toBeCloseTo(2, 9); + }); + + it("compiles min(max(x, 0.8), 0.5) → constant 0.5", async function () { + // Absorption: max result always >= 0.8 > 0.5, so min is always 0.5. + const r = await compileAndRun( + "{ dup 0.8 lt { pop 0.8 } { } ifelse " + + "dup 0.5 gt { pop 0.5 } { } ifelse }", + [0, 1], + [0, 1], + [0.3] + ); + expect(r).toBeCloseTo(0.5, 9); + }); + + it("min/max fold: upper clamp emits f64.min", async function () { + // x > 1 → clamp to 1; x ≤ 1 → pass through. + const r1 = await compileAndRun( + "{ dup 1 gt { pop 1 } { } ifelse }", + [0, 2], + [0, 2], + [2] + ); + expect(r1).toBeCloseTo(1, 9); + const r2 = await compileAndRun( + "{ dup 1 gt { pop 1 } { } ifelse }", + [0, 2], + [0, 2], + [0.5] + ); + expect(r2).toBeCloseTo(0.5, 9); + }); + + it("min/max fold: lower clamp emits f64.max", async function () { + // x < 0 → clamp to 0; x ≥ 0 → pass through. + const r1 = await compileAndRun( + "{ dup 0 lt { pop 0 } { } ifelse }", + [-1, 1], + [0, 1], + [-0.5] + ); + expect(r1).toBeCloseTo(0, 9); + const r2 = await compileAndRun( + "{ dup 0 lt { pop 0 } { } ifelse }", + [-1, 1], + [0, 1], + [0.5] + ); + expect(r2).toBeCloseTo(0.5, 9); + }); + }); + + // PSStackToTree + describe("PSStackToTree", function () { + /** Parse and convert to tree, returning the output node array. */ + function toTree(src, numInputs) { + const prog = parsePostScriptFunction(src); + return new PSStackToTree().evaluate(prog, numInputs); + } + + it("wraps inputs in PsArgNodes", function () { + // { } with 2 inputs — outputs are the two unmodified args + const out = toTree("{ }", 2); + expect(out.length).toBe(2); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + expect(out[1]).toBeInstanceOf(PsArgNode); + expect(out[1].index).toBe(1); + }); + + it("wraps number literals in PsConstNode", function () { + const out = toTree("{ 42 }", 0); + expect(out.length).toBe(1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(42); + }); + + it("produces a binary node for add", function () { + // { add } with 2 inputs → PsBinaryNode(add, in1, in0) + // first (top) = in1 (index 1) + // second (below) = in0 (index 0) + const out = toTree("{ add }", 2); + expect(out.length).toBe(1); + const node = out[0]; + expect(node).toBeInstanceOf(PsBinaryNode); + expect(node.op).toBe(TOKEN.add); + expect(node.first).toBeInstanceOf(PsArgNode); + expect(node.first.index).toBe(1); + expect(node.second).toBeInstanceOf(PsArgNode); + expect(node.second.index).toBe(0); + }); + + it("respects operand order for non-commutative sub", function () { + // { sub } with inputs in0 in1 → second=in0, first=in1 → result in0−in1 + const out = toTree("{ sub }", 2); + const node = out[0]; + expect(node).toBeInstanceOf(PsBinaryNode); + expect(node.op).toBe(TOKEN.sub); + expect(node.second.index).toBe(0); // minuend + expect(node.first.index).toBe(1); // subtrahend + }); + + it("produces a unary node for neg", function () { + const out = toTree("{ neg }", 1); + expect(out.length).toBe(1); + const node = out[0]; + expect(node).toBeInstanceOf(PsUnaryNode); + expect(node.op).toBe(TOKEN.neg); + expect(node.operand).toBeInstanceOf(PsArgNode); + expect(node.operand.index).toBe(0); + }); + + it("chains operations — example from spec: in1 in2 dup add mul", function () { + // Inputs: in0, in1 on stack. + // { dup add mul }: + // dup → [in0, in1, in1] + // add → first=in1, second=in1 → addNode + // mul → first=addNode, second=in0 → mulNode + const out = toTree("{ dup add mul }", 2); + expect(out.length).toBe(1); + const mul = out[0]; + expect(mul).toBeInstanceOf(PsBinaryNode); + expect(mul.op).toBe(TOKEN.mul); + // first (top at mul time) = result of add + expect(mul.first).toBeInstanceOf(PsBinaryNode); + expect(mul.first.op).toBe(TOKEN.add); + expect(mul.first.first).toBeInstanceOf(PsArgNode); + expect(mul.first.first.index).toBe(1); + expect(mul.first.second).toBeInstanceOf(PsArgNode); + expect(mul.first.second.index).toBe(1); + // second (below at mul time) = in0 + expect(mul.second).toBeInstanceOf(PsArgNode); + expect(mul.second.index).toBe(0); + }); + + it("dup shares the same node reference", function () { + // { dup } with 1 input → two references to the same PsArgNode + const out = toTree("{ dup }", 1); + expect(out.length).toBe(2); + expect(out[0]).toBe(out[1]); + }); + + it("exch swaps the top two nodes", function () { + const out = toTree("{ exch }", 2); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(1); // former top is now at position 0 + expect(out[1]).toBeInstanceOf(PsArgNode); + expect(out[1].index).toBe(0); + }); + + it("pop discards the top node", function () { + const out = toTree("{ pop }", 2); + expect(out.length).toBe(1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("true and false become PsConstNode", function () { + const out = toTree("{ true false }", 0); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(true); + expect(out[1]).toBeInstanceOf(PsConstNode); + expect(out[1].value).toBe(false); + }); + + it("copy duplicates the top n nodes", function () { + // { 2 copy } with 2 inputs → [in0, in1, in0, in1] + const out = toTree("{ 2 copy }", 2); + expect(out.length).toBe(4); + expect(out[0]).toBe(out[2]); + expect(out[1]).toBe(out[3]); + }); + + it("index copies the nth-from-top node", function () { + // { 1 index } with 2 inputs → [in0, in1, in0] (1 index = copy of in0) + const out = toTree("{ 1 index }", 2); + expect(out.length).toBe(3); + expect(out[2]).toBe(out[0]); + }); + + it("roll rotates the window correctly (n=2, j=1)", function () { + // { 2 1 roll } with 2 inputs → [in1, in0] (top goes to bottom) + const out = toTree("{ 2 1 roll }", 2); + expect(out.length).toBe(2); + expect(out[0].index).toBe(1); // former top + expect(out[1].index).toBe(0); // former bottom + }); + + it("ifelse produces PsTernaryNode", function () { + // { dup 0.5 gt { 2 mul } { 0.5 mul } ifelse } with 1 input + const out = toTree("{ dup 0.5 gt { 2 mul } { 0.5 mul } ifelse }", 1); + expect(out.length).toBe(1); + const tern = out[0]; + expect(tern).toBeInstanceOf(PsTernaryNode); + expect(tern.cond).toBeInstanceOf(PsBinaryNode); + expect(tern.cond.op).toBe(TOKEN.gt); + expect(tern.then).toBeInstanceOf(PsBinaryNode); + expect(tern.then.op).toBe(TOKEN.mul); + expect(tern.otherwise).toBeInstanceOf(PsBinaryNode); + expect(tern.otherwise.op).toBe(TOKEN.mul); + }); + + it("if with clamp pattern folds to max (min/max optimization)", function () { + // { dup 0 lt { pop 0 } if } with 1 input + // cond = in0 < 0; then = 0; otherwise = in0 + // → _makeTernary folds to max(in0, 0) via ternary→min/max rule. + const out = toTree("{ dup 0 lt { pop 0 } if }", 1); + expect(out.length).toBe(1); + const node = out[0]; + expect(node).toBeInstanceOf(PsBinaryNode); + expect(node.op).toBe(TOKEN.max); + }); + + it("handles stack-growing if (guard / early-exit pattern)", function () { + // { dup 0 le { pop 10 20 0 } if 0 gt { 30 40 } if } + // + // The guard `{ pop 10 20 0 }` fires when in0 ≤ 0 and replaces the + // scalar input with three values (two color values + sentinel 0). + // The sentinel ensures both paths converge to depth 2: + // in0 ≤ 0: [10, 20, 0] → `0 gt` = false → {30 40} skipped → [10, 20] + // in0 > 0: [in0] → `0 gt` always true → {30 40} fires → [30, 40] + const out = toTree( + "{ dup 0 le { pop 10 20 0 } if 0 gt { 30 40 } if }", + 1 + ); + expect(out).not.toBeNull(); + expect(out.length).toBe(2); + // The first output's top-level node selects between the early-exit value + // (10, when in0 ≤ 0) and the default path. + expect(out[0]).toBeInstanceOf(PsTernaryNode); + expect(out[0].then).toBeInstanceOf(PsConstNode); + expect(out[0].then.value).toBe(10); + expect(out[1]).toBeInstanceOf(PsTernaryNode); + expect(out[1].then).toBeInstanceOf(PsConstNode); + expect(out[1].then.value).toBe(20); + }); + + it("handles two chained stack-growing ifs (nested guard pattern)", function () { + // Two guards + final default — the sentinel mechanism ensures all three + // paths converge to depth 2: + // in0 ≤ 0: first guard fires → (10, 20) + // 0 < in0 ≤ 1: neither fires → default (50, 60) + // in0 > 1: second guard fires → (30, 40) + const out = toTree( + "{ dup 0 le { pop 10 20 0 } if" + + " dup 1 gt { pop 30 40 0 } if" + + " 0 gt { 50 60 } if }", + 1 + ); + expect(out).not.toBeNull(); + expect(out.length).toBe(2); + }); + + it("fails cleanly on if without a condition value", function () { + const out = toTree("{ { 1 } if }", 0); + expect(out).toBeNull(); + }); + + it("fails cleanly on ifelse without a condition value", function () { + const out = toTree("{ { 1 } { 2 } ifelse }", 0); + expect(out).toBeNull(); + }); + + // Optimisations + + // Constant folding + it("constant-folds a binary op when both operands are literals", function () { + const out = toTree("{ 3 4 add }", 0); + expect(out.length).toBe(1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(7); + }); + + it("constant-folds a unary op", function () { + const out = toTree("{ 9 sqrt }", 0); + expect(out.length).toBe(1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(3); + }); + + it("constant-folds a chain of ops", function () { + // 1 2 add → 3, then 3 4 mul → 12 + const out = toTree("{ 1 2 add 4 mul }", 0); + expect(out.length).toBe(1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(12); + }); + + // Identity elements + it("x + 0 → x", function () { + const out = toTree("{ 0 add }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("0 + x → x", function () { + // Push 0, then add: stack is [in0, 0], add → first=0, second=in0 + const out = toTree("{ 0 add }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("x - 0 → x", function () { + const out = toTree("{ 0 sub }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("x * 1 → x", function () { + const out = toTree("{ 1 mul }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("1 * x → x", function () { + // Push 1 onto stack before the arg, then mul + const out = toTree("{ 1 exch mul }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("x / 1 → x", function () { + const out = toTree("{ 1 div }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + // Absorbing elements + it("x * 0 → 0", function () { + const out = toTree("{ 0 mul }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + it("0 * x → 0", function () { + const out = toTree("{ 0 exch mul }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + it("x ^ 1 → x", function () { + const out = toTree("{ 1 exp }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("x ^ 0 → 1", function () { + const out = toTree("{ 0 exp }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(1); + }); + + // Double-negation elimination + it("neg(neg(x)) → x", function () { + const out = toTree("{ neg neg }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("not(not(x)) → x", function () { + const out = toTree("{ not not }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("abs(neg(x)) → abs(x)", function () { + const out = toTree("{ neg abs }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.abs); + expect(out[0].operand).toBeInstanceOf(PsArgNode); + }); + + it("abs(abs(x)) → abs(x)", function () { + const out = toTree("{ abs abs }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.abs); + expect(out[0].operand).toBeInstanceOf(PsArgNode); + }); + + // Boolean identities + it("x and true → x", function () { + const out = toTree("{ true and }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("x and false → false", function () { + const out = toTree("{ false and }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(false); + }); + + it("x or false → x", function () { + const out = toTree("{ false or }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("x or true → true", function () { + const out = toTree("{ true or }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(true); + }); + + // not(comparison) → negated comparison + it("not(a eq b) → a ne b", function () { + const out = toTree("{ eq not }", 2); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.ne); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.boolean); + }); + + it("not(a lt b) → a ge b", function () { + const out = toTree("{ lt not }", 2); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.ge); + }); + + it("not(a ge b) → a lt b", function () { + const out = toTree("{ ge not }", 2); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.lt); + }); + + // Value types + + it("PsArgNode has numeric valueType", function () { + const out = toTree("{ }", 1); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.numeric); + }); + + it("PsConstNode(number) has numeric valueType", function () { + const out = toTree("{ pop 1 }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.numeric); + }); + + it("PsConstNode(boolean) has boolean valueType", function () { + const out = toTree("{ pop true }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.boolean); + }); + + it("comparison result has boolean valueType", function () { + const out = toTree("{ 0.5 gt }", 1); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.boolean); + }); + + it("arithmetic result has numeric valueType", function () { + const out = toTree("{ 2 add }", 1); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.numeric); + }); + + it("not of boolean has boolean valueType", function () { + const out = toTree("{ 0.5 gt not }", 1); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.boolean); + }); + + it("not of numeric has numeric valueType", function () { + const out = toTree("{ not }", 1); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.numeric); + }); + + // Reflexive simplifications (x op x) + + it("x - x → 0 (reflexive sub)", function () { + const out = toTree("{ dup sub }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + it("x xor x → 0 (reflexive xor, integer)", function () { + // arg0 has numeric valueType, so the result is integer 0 (not boolean + // false). + const out = toTree("{ dup xor }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + it("x eq x → true (reflexive eq)", function () { + const out = toTree("{ dup eq }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(true); + }); + + it("x and x → x (reflexive and)", function () { + const out = toTree("{ dup and }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("x ne x → false (reflexive ne)", function () { + const out = toTree("{ dup ne }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(false); + }); + + it("_nodesEqual handles structurally-equal unary nodes", function () { + // dup sqrt exch sqrt sub: two independent sqrt(arg0) nodes are + // structurally equal → reflexive sub → 0. + const out = toTree("{ dup sqrt exch sqrt sub }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + it("_nodesEqual handles structurally-equal binary nodes", function () { + // dup 2 mul exch 2 mul sub: two independent mul(2, arg0) nodes are + // structurally equal → reflexive sub → 0; exercises the binary branch + // of _nodesEqual (checking both first and second sub-operands). + const out = toTree("{ dup 2 mul exch 2 mul sub }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(0); + }); + + // Algebraic simplifications — first is a known constant + + it("x * -1 → neg(x)", function () { + const out = toTree("{ -1 mul }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.neg); + expect(out[0].operand).toBeInstanceOf(PsArgNode); + }); + + it("x idiv 1 → x", function () { + const out = toTree("{ 1 idiv }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("x ^ 0.5 → sqrt(x)", function () { + const out = toTree("{ 0.5 exp }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.sqrt); + }); + + it("x ^ -1 → 1 / x", function () { + const out = toTree("{ -1 exp }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.div); + expect(out[0].second).toBeInstanceOf(PsConstNode); + expect(out[0].second.value).toBe(1); + }); + + it("x ^ 3 → (x * x) * x", function () { + const out = toTree("{ 3 exp }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.mul); + expect(out[0].first).toBeInstanceOf(PsBinaryNode); + expect(out[0].first.op).toBe(TOKEN.mul); + expect(out[0].second).toBeInstanceOf(PsArgNode); + }); + + it("x ^ 4 → (x * x) * (x * x)", function () { + const out = toTree("{ 4 exp }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.mul); + expect(out[0].first).toBe(out[0].second); + expect(out[0].first).toBeInstanceOf(PsBinaryNode); + expect(out[0].first.op).toBe(TOKEN.mul); + }); + + // Algebraic simplifications — second (left operand) is a known constant + + it("0 + x → x (second=0 add)", function () { + // Push 0 first (below), then arg0 on top; add has second=PsConstNode(0). + const out = toTree("{ 0 exch add }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("0 - x → neg(x) (second=0 sub)", function () { + const out = toTree("{ 0 exch sub }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.neg); + }); + + it("-1 * x → neg(x) (second=-1 mul)", function () { + const out = toTree("{ -1 exch mul }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.neg); + }); + + it("true and x → x (second=true and)", function () { + const out = toTree("{ true exch and }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("false and x → false (second=false and)", function () { + const out = toTree("{ false exch and }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(false); + }); + + it("false or x → x (second=false or)", function () { + const out = toTree("{ false exch or }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("true or x → true (second=true or)", function () { + const out = toTree("{ true exch or }", 1); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBe(true); + }); + + it("no simplification when second operand is a non-special constant", function () { + // Exercises the break paths in the second.type===const algebraic section + // when none of the special-case thresholds (0, 1, -1, true, false) match. + expect(toTree("{ 0.5 exch add }", 1)[0]).toBeInstanceOf(PsBinaryNode); // a≠0 + expect(toTree("{ 0.5 exch sub }", 1)[0]).toBeInstanceOf(PsBinaryNode); // a≠0 + expect(toTree("{ 0.5 exch mul }", 1)[0]).toBeInstanceOf(PsBinaryNode); // a≠±1,0 + expect(toTree("{ 0.5 exch and }", 1)[0]).toBeInstanceOf(PsBinaryNode); // a≠boolean + expect(toTree("{ 0.5 exch or }", 1)[0]).toBeInstanceOf(PsBinaryNode); // a≠boolean + }); + + it("no simplification when first operand is a non-special constant", function () { + // Exercises the break paths in the first.type===const algebraic section. + // In PostScript `x c op`, first=PsConstNode(c), second=PsArgNode. + // sub: b=2≠0 → break (line 915) + expect(toTree("{ 2 sub }", 1)[0]).toBeInstanceOf(PsBinaryNode); + // div: b=0 → the "if (b !== 0)" is false → break (line 933) + expect(toTree("{ 0 div }", 1)[0]).toBeInstanceOf(PsBinaryNode); + // idiv: b=2≠1 → break (line 938) + expect(toTree("{ 2 idiv }", 1)[0]).toBeInstanceOf(PsBinaryNode); + // exp: b=5 (not 0, 0.5, 0.25, -1, 1, 2, 3, or 4) → break + expect(toTree("{ 5 exp }", 1)[0]).toBeInstanceOf(PsBinaryNode); + // and: b=2 (not boolean) → break (line 961) + expect(toTree("{ 2 and }", 1)[0]).toBeInstanceOf(PsBinaryNode); + // or: b=2 (not boolean) → break (line 969) + expect(toTree("{ 2 or }", 1)[0]).toBeInstanceOf(PsBinaryNode); + }); + + // _makeTernary optimizations + + it("_makeTernary folds constant-true condition to then-branch", function () { + const out = toTree("{ true { 2 } { 3 } ifelse }", 0); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(2); + }); + + it("_makeTernary returns shared branch when then and otherwise are identical", function () { + // Both branches are empty, so thenStack = elseStack = [arg0]. + // _makeTernary(cond, arg0, arg0) → _nodesEqual → returns arg0. + const out = toTree("{ dup 0.5 gt { } { } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + expect(out[0].index).toBe(0); + }); + + it("_makeTernary simplifies cond ? true : false → cond", function () { + // The ternary with boolean branches is simplified to just the condition. + const out = toTree("{ 0.5 gt { true } { false } ifelse }", 1); + // Result should be the comparison node itself (not a ternary). + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.gt); + }); + + it("_makeTernary simplifies cond ? false : true → not(cond)", function () { + // not(gt) is further simplified to le by the not(comparison) rule. + const out = toTree("{ 0.5 gt { false } { true } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.le); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.boolean); + }); + + it("_makeTernary assigns PS_VALUE_TYPE.unknown when branches have different types", function () { + // Then = boolean (comparison), otherwise = numeric (mul) → unknown. + const out = toTree("{ dup 0.5 gt { 0.5 gt } { 2 mul } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsTernaryNode); + expect(out[0].valueType).toBe(PS_VALUE_TYPE.unknown); + }); + + // _makeTernary → min/max folding + + it("_makeTernary folds (x gt c) ? c : x → min(x, c)", function () { + // { dup 1 gt { pop 1 } { } ifelse } with 1 input: + // thenStack=[1], elseStack=[x]; cond=gt(first=1, second=x) + // → _makeTernary(gt(1,x), 1, x) → min(x, 1) + const out = toTree("{ dup 1 gt { pop 1 } { } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.min); + }); + + it("_makeTernary folds (x lt c) ? c : x → max(x, c)", function () { + // { dup 0 lt { pop 0 } { } ifelse } → max(x, 0) + const out = toTree("{ dup 0 lt { pop 0 } { } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.max); + }); + + it("_makeTernary folds (x lt c) ? x : c → min(x, c)", function () { + // { dup 0 lt { } { pop 0 } ifelse } → min(x, 0): + // cond=lt(cf=0, cs=x); then=x=cs, otherwise=0=cf → min(cf, cs) + const out = toTree("{ dup 0 lt { } { pop 0 } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.min); + }); + + it("_makeTernary folds (x ge c) ? c : x → min(x, c)", function () { + // { dup 0.5 ge { pop 0.5 } { } ifelse } → min(x, 0.5) + const out = toTree("{ dup 0.5 ge { pop 0.5 } { } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.min); + }); + + it("_makeTernary folds (x le c) ? c : x → max(x, c)", function () { + // { dup 0.5 le { pop 0.5 } { } ifelse } → max(x, 0.5) + const out = toTree("{ dup 0.5 le { pop 0.5 } { } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.max); + }); + + it("_makeTernary folds (x gt c) ? x : c → max(x, c)", function () { + // { dup 0.5 gt { } { pop 0.5 } ifelse } → max(x, 0.5) + const out = toTree("{ dup 0.5 gt { } { pop 0.5 } ifelse }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.max); + }); + + // min/max identity and absorption + + it("neg(a − b) → b − a (sub operand swap)", function () { + // neg(x - 1) should become (1 - x), i.e. PsBinaryNode(sub) + // with the operands swapped — no PsUnaryNode(neg) in the tree. + const out = toTree("{ 1 sub neg }", 1); + expect(out[0]).toBeInstanceOf(PsBinaryNode); + expect(out[0].op).toBe(TOKEN.sub); + // first=arg (original second of the inner sub), second=PsConstNode(1) + expect(out[0].second).toBeInstanceOf(PsConstNode); + expect(out[0].second.value).toBe(1); + expect(out[0].first).toBeInstanceOf(PsArgNode); + }); + + it("neg(0 − x) → x (neg(−x) double elimination)", function () { + // 0 − x → neg(x) via the second=0 rule, then neg(neg(x)) → x. + const out = toTree("{ 0 exch sub neg }", 1); + expect(out[0]).toBeInstanceOf(PsArgNode); + }); + + it("min(max(x, c2), c1) where c2 >= c1 → c1 (absorption)", function () { + // max(x, 0.8) always >= 0.8 > 0.5, so min(..., 0.5) = 0.5. + // { dup 0.8 lt { pop 0.8 } { } ifelse ← max(x, 0.8) + // dup 0.5 gt { pop 0.5 } { } ifelse } ← min(..., 0.5) + const out = toTree( + "{ dup 0.8 lt { pop 0.8 } { } ifelse " + + "dup 0.5 gt { pop 0.5 } { } ifelse }", + 1 + ); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(0.5, 9); + }); + + it("max(min(x, c1), c2) where c2 >= c1 → c2 (absorption)", function () { + // min(x, 0.2) always <= 0.2 < 0.5, so max(..., 0.5) = 0.5. + const out = toTree( + "{ dup 0.2 gt { pop 0.2 } { } ifelse " + + "dup 0.5 lt { pop 0.5 } { } ifelse }", + 1 + ); + expect(out[0]).toBeInstanceOf(PsConstNode); + expect(out[0].value).toBeCloseTo(0.5, 9); + }); + + it("x ^ 0.25 folds to sqrt(sqrt(x)) — no PsTernaryNode", function () { + // Should produce PsUnaryNode(sqrt, PsUnaryNode(sqrt, arg)) + const out = toTree("{ 0.25 exp }", 1); + expect(out[0]).toBeInstanceOf(PsUnaryNode); + expect(out[0].op).toBe(TOKEN.sqrt); + expect(out[0].operand).toBeInstanceOf(PsUnaryNode); + expect(out[0].operand.op).toBe(TOKEN.sqrt); + }); + + // Constant folding — binary ops beyond add/sub/mul + + it("constant-folds sub, div, idiv, mod", function () { + expect(toTree("{ 5 3 sub }", 0)[0].value).toBeCloseTo(2); // sub const fold + expect(toTree("{ 6 3 div }", 0)[0].value).toBeCloseTo(2); + expect(toTree("{ 7 3 idiv }", 0)[0].value).toBeCloseTo(2); // trunc(7/3)=2 + expect(toTree("{ 7 3 mod }", 0)[0].value).toBeCloseTo(1); // 7 - 2*3 = 1 + }); + + it("constant-folds exp and atan (including negative angle)", function () { + expect(toTree("{ 3 3 exp }", 0)[0].value).toBeCloseTo(27); + expect(toTree("{ 1 1 atan }", 0)[0].value).toBeCloseTo(45); // atan(1,1)=45° + // Negative atan result gets normalised: atan(-1,1)=-45° → +360 → 315° + expect(toTree("{ -1 1 atan }", 0)[0].value).toBeCloseTo(315); + }); + + it("constant-folds comparison operators", function () { + expect(toTree("{ 1 1 eq }", 0)[0].value).toBe(true); + expect(toTree("{ 1 2 ne }", 0)[0].value).toBe(true); + expect(toTree("{ 2 1 gt }", 0)[0].value).toBe(true); // a=2 > b=1 + expect(toTree("{ 1 1 ge }", 0)[0].value).toBe(true); + expect(toTree("{ 1 2 lt }", 0)[0].value).toBe(true); // a=1 < b=2 + expect(toTree("{ 1 2 le }", 0)[0].value).toBe(true); + }); + + it("constant-folds boolean and, or, xor and bitshift", function () { + expect(toTree("{ true false and }", 0)[0].value).toBe(false); + expect(toTree("{ false true or }", 0)[0].value).toBe(true); + expect(toTree("{ true false xor }", 0)[0].value).toBe(true); + expect(toTree("{ 4 2 bitshift }", 0)[0].value).toBe(16); // 4 << 2 + }); + + // Constant folding — unary ops + + it("constant-folds abs, neg, ceiling, floor, round, truncate", function () { + expect(toTree("{ 2.5 abs }", 0)[0].value).toBeCloseTo(2.5); + expect(toTree("{ 2.5 neg }", 0)[0].value).toBeCloseTo(-2.5); + expect(toTree("{ 2.5 ceiling }", 0)[0].value).toBeCloseTo(3); + expect(toTree("{ 2.5 floor }", 0)[0].value).toBeCloseTo(2); + expect(toTree("{ 2.5 round }", 0)[0].value).toBeCloseTo(3); + expect(toTree("{ 2.5 truncate }", 0)[0].value).toBeCloseTo(2); + }); + + it("constant-folds sin, cos, ln, log, cvi, cvr", function () { + expect(toTree("{ 30 sin }", 0)[0].value).toBeCloseTo(0.5, 9); + expect(toTree("{ 0 cos }", 0)[0].value).toBeCloseTo(1, 9); + expect(toTree("{ 1 ln }", 0)[0].value).toBeCloseTo(0, 9); + expect(toTree("{ 100 log }", 0)[0].value).toBeCloseTo(2, 9); + expect(toTree("{ 2.7 cvi }", 0)[0].value).toBe(2); // Math.trunc(2.7) + expect(toTree("{ 2.7 cvr }", 0)[0].value).toBeCloseTo(2.7, 9); + }); + }); +});