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