Add a js fallback for interpreting ps code

It's a basic stack based interpreter.
A wasm version will come soon.
This commit is contained in:
calixteman 2026-04-01 21:40:34 +02:00
parent 399fce6471
commit 8c7a5f3500
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
3 changed files with 328 additions and 92 deletions

View File

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

View File

@ -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,
};

View File

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