diff --git a/src/core/function.js b/src/core/function.js index 2bfa150d6..cf5fce896 100644 --- a/src/core/function.js +++ b/src/core/function.js @@ -15,7 +15,6 @@ import { Dict, Ref } from "./primitives.js"; import { - FeatureTest, FormatError, info, MathClamp, @@ -23,7 +22,6 @@ import { unreachable, warn, } from "../shared/util.js"; -import { PostScriptLexer, PostScriptParser } from "./ps_parser.js"; import { BaseStream } from "./base_stream.js"; import { buildPostScriptJsFunction } from "./postscript/js_evaluator.js"; import { buildPostScriptWasmFunction } from "./postscript/wasm_compiler.js"; @@ -371,89 +369,20 @@ class PDFFunction { throw new FormatError("No range."); } + const psCode = fn.getString(); + try { if (factory.useWasm) { - const wasmFn = buildPostScriptWasmFunction( - fn.getString(), - domain, - range - ); + const wasmFn = buildPostScriptWasmFunction(psCode, domain, range); if (wasmFn) { return wasmFn; // (src, srcOffset, dest, destOffset) → void } - } else { - const jsFn = buildPostScriptJsFunction(fn.getString(), domain, range); - if (jsFn) { - return jsFn; // (src, srcOffset, dest, destOffset) → void - } } - } catch { - // Fall through to the existing interpreter-based path. - } + } catch {} - warn("Unable to compile PS function, using interpreter"); - fn.reset(); + warn("Failed to compile PostScript function to wasm, falling back to JS"); - const lexer = new PostScriptLexer(fn); - const parser = new PostScriptParser(lexer); - const code = parser.parse(); - - if (factory.isEvalSupported && FeatureTest.isEvalSupported) { - const compiled = new PostScriptCompiler().compile(code, domain, range); - if (compiled) { - // Compiled function consists of simple expressions such as addition, - // subtraction, Math.max, and also contains 'var' and 'return' - // statements. See the generation in the PostScriptCompiler below. - // eslint-disable-next-line no-new-func - return new Function("src", "srcOffset", "dest", "destOffset", compiled); - } - } - info("Unable to compile PS function"); - - const numOutputs = range.length >> 1; - const numInputs = domain.length >> 1; - const evaluator = new PostScriptEvaluator(code); - // Cache the values for a big speed up, the cache size is limited though - // since the number of possible values can be huge from a PS function. - const cache = Object.create(null); - // The MAX_CACHE_SIZE is set to ~4x the maximum number of distinct values - // seen in our tests. - const MAX_CACHE_SIZE = 2048 * 4; - let cache_available = MAX_CACHE_SIZE; - const tmpBuf = new Float32Array(numInputs); - - return function constructPostScriptFn(src, srcOffset, dest, destOffset) { - let i, value; - let key = ""; - const input = tmpBuf; - for (i = 0; i < numInputs; i++) { - value = src[srcOffset + i]; - input[i] = value; - key += value + "_"; - } - - const cachedValue = cache[key]; - if (cachedValue !== undefined) { - dest.set(cachedValue, destOffset); - return; - } - - const output = new Float32Array(numOutputs); - const stack = evaluator.execute(input); - const stackIndex = stack.length - numOutputs; - for (i = 0; i < numOutputs; i++) { - output[i] = MathClamp( - stack[stackIndex + i], - range[i * 2], - range[i * 2 + 1] - ); - } - if (cache_available > 0) { - cache_available--; - cache[key] = output; - } - dest.set(output, destOffset); - }; + return buildPostScriptJsFunction(psCode, domain, range); } } diff --git a/src/core/postscript/js_evaluator.js b/src/core/postscript/js_evaluator.js index 8bcd77c20..220455fc8 100644 --- a/src/core/postscript/js_evaluator.js +++ b/src/core/postscript/js_evaluator.js @@ -528,23 +528,311 @@ function compilePostScriptToIR(source, domain, range) { } /** - * Same calling convention as the Wasm wrapper: - * fn(src, srcOffset, dest, destOffset) + * Direct stack-based interpreter for a parsed PsProgram. + * Used when PSStackToTree fails to optimize the AST. + */ +class PSStackBasedInterpreter { + // Safe: JS is single-threaded. + static #stack = new Float64Array(100); + + static #sp = 0; + + static #push(v) { + if (this.#sp < this.#stack.length) { + this.#stack[this.#sp++] = v; + } + } + + static #execOp(op) { + const stack = this.#stack; + switch (op) { + case TOKEN.true: + this.#push(1); + break; + case TOKEN.false: + this.#push(0); + break; + case TOKEN.abs: + stack[this.#sp - 1] = Math.abs(stack[this.#sp - 1]); + break; + case TOKEN.neg: + stack[this.#sp - 1] = -stack[this.#sp - 1]; + break; + case TOKEN.ceiling: + stack[this.#sp - 1] = Math.ceil(stack[this.#sp - 1]); + break; + case TOKEN.floor: + stack[this.#sp - 1] = Math.floor(stack[this.#sp - 1]); + break; + case TOKEN.round: + stack[this.#sp - 1] = Math.floor(stack[this.#sp - 1] + 0.5); + break; + case TOKEN.truncate: + stack[this.#sp - 1] = Math.trunc(stack[this.#sp - 1]); + break; + case TOKEN.sqrt: + stack[this.#sp - 1] = Math.sqrt(stack[this.#sp - 1]); + break; + case TOKEN.sin: + stack[this.#sp - 1] = Math.sin( + (stack[this.#sp - 1] % 360) * _DEG_TO_RAD + ); + break; + case TOKEN.cos: + stack[this.#sp - 1] = Math.cos( + (stack[this.#sp - 1] % 360) * _DEG_TO_RAD + ); + break; + case TOKEN.ln: + stack[this.#sp - 1] = Math.log(stack[this.#sp - 1]); + break; + case TOKEN.log: + stack[this.#sp - 1] = Math.log10(stack[this.#sp - 1]); + break; + case TOKEN.cvi: + stack[this.#sp - 1] = Math.trunc(stack[this.#sp - 1]) | 0; + break; + case TOKEN.cvr: + break; // values are already f64 + case TOKEN.not: { + const v = stack[this.#sp - 1]; + stack[this.#sp - 1] = v === 0 || v === 1 ? 1 - v : ~(v | 0); + break; + } + case TOKEN.add: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] += b; + break; + } + case TOKEN.sub: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] -= b; + break; + } + case TOKEN.mul: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] *= b; + break; + } + case TOKEN.div: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = b !== 0 ? stack[this.#sp - 1] / b : 0; + break; + } + case TOKEN.idiv: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = b !== 0 ? Math.trunc(stack[this.#sp - 1] / b) : 0; + break; + } + case TOKEN.mod: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = b !== 0 ? stack[this.#sp - 1] % b : 0; + break; + } + case TOKEN.exp: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] **= b; + break; + } + case TOKEN.atan: { + // Stack: [..., dy, dx] — dx on top. + const dx = stack[--this.#sp]; + const deg = Math.atan2(stack[this.#sp - 1], dx) * _RAD_TO_DEG; + stack[this.#sp - 1] = deg < 0 ? deg + 360 : deg; + break; + } + case TOKEN.eq: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] === b ? 1 : 0; + break; + } + case TOKEN.ne: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] !== b ? 1 : 0; + break; + } + case TOKEN.gt: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] > b ? 1 : 0; + break; + } + case TOKEN.ge: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] >= b ? 1 : 0; + break; + } + case TOKEN.lt: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] < b ? 1 : 0; + break; + } + case TOKEN.le: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = stack[this.#sp - 1] <= b ? 1 : 0; + break; + } + case TOKEN.and: { + const b = stack[--this.#sp] | 0; + stack[this.#sp - 1] = (stack[this.#sp - 1] | 0) & b; + break; + } + case TOKEN.or: { + const b = stack[--this.#sp] | 0; + stack[this.#sp - 1] = stack[this.#sp - 1] | 0 | b; + break; + } + case TOKEN.xor: { + const b = stack[--this.#sp] | 0; + stack[this.#sp - 1] = (stack[this.#sp - 1] | 0) ^ b; + break; + } + case TOKEN.bitshift: { + const amt = stack[--this.#sp] | 0; + const v = stack[this.#sp - 1] | 0; + stack[this.#sp - 1] = amt > 0 ? v << amt : v >> -amt; + break; + } + case TOKEN.min: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = Math.min(stack[this.#sp - 1], b); + break; + } + case TOKEN.max: { + const b = stack[--this.#sp]; + stack[this.#sp - 1] = Math.max(stack[this.#sp - 1], b); + break; + } + case TOKEN.dup: + this.#push(stack[this.#sp - 1]); + break; + case TOKEN.exch: { + const a = stack[--this.#sp]; + const b = stack[--this.#sp]; + this.#push(a); + this.#push(b); + break; + } + case TOKEN.pop: + this.#sp--; + break; + case TOKEN.copy: { + const n = Math.trunc(stack[--this.#sp]); + const base = this.#sp - n; + for (let k = 0; k < n; k++) { + this.#push(stack[base + k]); + } + break; + } + case TOKEN.index: { + const i = Math.trunc(stack[--this.#sp]); + this.#push(stack[this.#sp - 1 - i]); + break; + } + case TOKEN.roll: { + // Rotate top n elements by j positions toward the top. + const j = Math.trunc(stack[--this.#sp]); + const n = Math.trunc(stack[--this.#sp]); + if (n > 1 && j !== 0) { + const mod = ((j % n) + n) % n; + if (mod !== 0) { + const base = this.#sp - n; + const sub = stack.slice(base, this.#sp); + for (let k = 0; k < n; k++) { + stack[base + k] = sub[(k - mod + n) % n]; + } + } + } + break; + } + } + } + + static #execBlock(instructions) { + for (const instr of instructions) { + switch (instr.type) { + case PS_NODE.number: + this.#push(instr.value); + break; + case PS_NODE.operator: + this.#execOp(instr.op); + break; + case PS_NODE.if: + if (this.#stack[--this.#sp] !== 0) { + this.#execBlock(instr.then.instructions); + } + break; + case PS_NODE.ifelse: + if (this.#stack[--this.#sp] !== 0) { + this.#execBlock(instr.then.instructions); + } else { + this.#execBlock(instr.otherwise.instructions); + } + break; + } + } + } + + /** + * @param {import("./ast.js").PsProgram} program + * @param {number[]} domain – flat [min0,max0, …] + * @param {number[]} range – flat [min0,max0, …] + * @returns {Function} – `(src, srcOffset, dest, destOffset) => void` + */ + static build(program, domain, range) { + const nIn = domain.length >> 1; + const nOut = range.length >> 1; + const { instructions } = program.body; + return (src, srcOffset, dest, destOffset) => { + this.#sp = 0; + for (let i = 0; i < nIn; i++) { + this.#push(src[srcOffset + i]); + } + this.#execBlock(instructions); + // Outputs: first at bottom, last at top. + const base = this.#sp - nOut; + for (let i = 0; i < nOut; i++) { + const v = base + i >= 0 ? this.#stack[base + i] : 0; + dest[destOffset + i] = Math.max( + range[i * 2], + Math.min(range[i * 2 + 1], v) + ); + } + }; + } +} + +/** + * Tries PSStackToTree-optimized IR first; falls back to direct interpreter. * * @param {string} source * @param {number[]} domain – flat [min0,max0, …] * @param {number[]} range – flat [min0,max0, …] - * @returns {Function|null} + * @returns {Function} – `(src, srcOffset, dest, destOffset) => void` */ function buildPostScriptJsFunction(source, domain, range) { - const ir = compilePostScriptToIR(source, domain, range); - if (!ir) { - return null; + const program = parsePostScriptFunction(source); + const ir = new PsJsCompiler(domain, range).compile(program); + if (ir) { + return (src, srcOffset, dest, destOffset) => { + PsJsCompiler.execute(ir, src, srcOffset, dest, destOffset); + }; } - - return (src, srcOffset, dest, destOffset) => { - PsJsCompiler.execute(ir, src, srcOffset, dest, destOffset); - }; + // Fall back to direct interpreter. + return PSStackBasedInterpreter.build(program, domain, range); } -export { buildPostScriptJsFunction, compilePostScriptToIR }; +/** + * @param {import("./ast.js").PsProgram} program + * @param {number[]} domain – flat [min0,max0, …] + * @param {number[]} range – flat [min0,max0, …] + * @returns {Function} – `(src, srcOffset, dest, destOffset) => void` + */ +function buildPostScriptProgramFunction(program, domain, range) { + return PSStackBasedInterpreter.build(program, domain, range); +} + +export { + buildPostScriptJsFunction, + buildPostScriptProgramFunction, + compilePostScriptToIR, +}; diff --git a/test/unit/postscript_spec.js b/test/unit/postscript_spec.js index 569a3ad23..5ad7d69e9 100644 --- a/test/unit/postscript_spec.js +++ b/test/unit/postscript_spec.js @@ -13,6 +13,10 @@ * limitations under the License. */ +import { + buildPostScriptJsFunction, + buildPostScriptProgramFunction, +} from "../../src/core/postscript/js_evaluator.js"; import { buildPostScriptWasmFunction, compilePostScriptToWasm, @@ -35,7 +39,6 @@ import { PsTernaryNode, PsUnaryNode, } from "../../src/core/postscript/ast.js"; -import { buildPostScriptJsFunction } from "../../src/core/postscript/js_evaluator.js"; // Precision argument for toBeCloseTo() in trigonometric tests. const TRIGONOMETRY_EPS = 1e-10; @@ -192,25 +195,41 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () { 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). + * Returns null if Wasm compilation returns null (unsupported program). * For single-output functions returns a scalar; for multi-output an Array. + * + * Validates three implementations against each other: + * - Wasm compiler (PSStackToTree → Wasm binary) + * - JS IR compiler (PSStackToTree → flat IR interpreted in JS) + * - Direct program interpreter (raw PsProgram stack-machine interpreter) */ function compileAndRun(src, domain, range, args) { const wasmFn = buildPostScriptWasmFunction(src, domain, range); + // jsFn now always returns a function: PSStackToTree IR when possible, + // direct program interpreter otherwise. const jsFn = buildPostScriptJsFunction(src, domain, range); + // Direct interpreter: always available, never uses PSStackToTree. + const interpFn = buildPostScriptProgramFunction( + parsePostScriptFunction(src), + domain, + range + ); + if (!wasmFn) { - expect(jsFn).toBeNull(); return null; } - expect(jsFn).not.toBeNull(); + const nOut = range.length >> 1; const srcBuf = new Float64Array(args); const wasmDest = new Float64Array(nOut); const jsDest = new Float64Array(nOut); + const interpDest = new Float64Array(nOut); wasmFn(srcBuf, 0, wasmDest, 0); jsFn(srcBuf, 0, jsDest, 0); + interpFn(srcBuf, 0, interpDest, 0); for (let i = 0; i < nOut; i++) { expect(jsDest[i]).toBeCloseTo(wasmDest[i], 10); + expect(interpDest[i]).toBeCloseTo(wasmDest[i], 10); } return nOut === 1 ? wasmDest[0] : Array.from(wasmDest); }