Merge pull request #21010 from calixteman/ps_js

Add an interpreter for optimized ps code
This commit is contained in:
calixteman 2026-03-31 22:21:00 +02:00 committed by GitHub
commit 399fce6471
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1217 additions and 176 deletions

View File

@ -2136,6 +2136,22 @@ class PDFDocument {
return obj;
}
if (dict.get("FunctionType") === 4) {
const source = value.getString();
value.reset();
const domain = dict.get("Domain") ?? [];
const range = dict.get("Range") ?? [];
obj.psFunction = true;
obj.source = source;
obj.psLines = InternalViewerUtils.tokenizePSSource(source);
obj.jsCode = InternalViewerUtils.postScriptToJSCode(
source,
domain,
range
);
return obj;
}
obj.bytes = value.getString();
return obj;
}

View File

@ -25,6 +25,7 @@ import {
} 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";
import { isNumberArray } from "./core_utils.js";
import { LocalFunctionCache } from "./image_utils.js";
@ -370,8 +371,8 @@ class PDFFunction {
throw new FormatError("No range.");
}
if (factory.useWasm) {
try {
try {
if (factory.useWasm) {
const wasmFn = buildPostScriptWasmFunction(
fn.getString(),
domain,
@ -380,9 +381,14 @@ class PDFFunction {
if (wasmFn) {
return wasmFn; // (src, srcOffset, dest, destOffset) → void
}
} catch {
// Fall through to the existing interpreter-based path.
} 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.
}
warn("Unable to compile PS function, using interpreter");

View File

@ -16,6 +16,13 @@
import { Cmd, Dict, EOF, Name, Ref } from "./primitives.js";
import { Lexer, Parser } from "./parser.js";
import { OPS, shadow } from "../shared/util.js";
import {
parsePostScriptFunction,
PS_NODE,
PS_VALUE_TYPE,
PSStackToTree,
} from "./postscript/ast.js";
import { Lexer as PsLexer, TOKEN } from "./postscript/lexer.js";
import { BaseStream } from "./base_stream.js";
import { EvaluatorPreprocessor } from "./evaluator.js";
@ -26,6 +33,262 @@ if (
throw new Error("Not implemented: InternalViewerUtils");
}
// JS operator precedence levels.
const PREC = {
ATOM: 100, // literals, identifiers, calls — never need parens
UNARY: 14,
POW: 13, // right-associative
MUL: 12,
ADD: 11,
SHIFT: 10,
CMP: 9,
EQ: 8,
BAND: 7,
BXOR: 6,
BOR: 5,
TERNARY: 3,
};
// Wrap left (or commutative) operand when child prec < op prec.
function _wrapLeft(child, opPrec) {
return child.prec < opPrec ? `(${child.expr})` : child.expr;
}
// Wrap right operand (or left of **) when child prec <= op prec.
function _wrapRight(child, opPrec) {
return child.prec <= opPrec ? `(${child.expr})` : child.expr;
}
function _nodeToExpr(node, argNames, cseMap) {
if (cseMap?.has(node)) {
return { expr: cseMap.get(node), prec: PREC.ATOM };
}
switch (node.type) {
case PS_NODE.arg:
return { expr: argNames[node.index], prec: PREC.ATOM };
case PS_NODE.const: {
const v = node.value;
return {
expr: String(typeof v === "boolean" ? Number(v) : v),
prec: PREC.ATOM,
};
}
case PS_NODE.unary:
return _unaryToExpr(node, argNames, cseMap);
case PS_NODE.binary:
return _binaryToExpr(node, argNames, cseMap);
case PS_NODE.ternary:
return _ternaryToExpr(node, argNames, cseMap);
default:
return null;
}
}
function _unaryToExpr(node, argNames, cseMap) {
const { op, operand, valueType } = node;
if (op === TOKEN.cvr) {
return _nodeToExpr(operand, argNames, cseMap);
}
const x = _nodeToExpr(operand, argNames, cseMap);
if (x === null) {
return null;
}
switch (op) {
case TOKEN.abs:
return { expr: `Math.abs(${x.expr})`, prec: PREC.ATOM };
case TOKEN.neg:
return { expr: `-${_wrapLeft(x, PREC.UNARY)}`, prec: PREC.UNARY };
case TOKEN.ceiling:
return { expr: `Math.ceil(${x.expr})`, prec: PREC.ATOM };
case TOKEN.floor:
return { expr: `Math.floor(${x.expr})`, prec: PREC.ATOM };
case TOKEN.round:
return {
expr: `Math.floor(${_wrapLeft(x, PREC.ADD)} + 0.5)`,
prec: PREC.ATOM,
};
case TOKEN.truncate:
return { expr: `Math.trunc(${x.expr})`, prec: PREC.ATOM };
case TOKEN.sqrt:
return { expr: `Math.sqrt(${x.expr})`, prec: PREC.ATOM };
case TOKEN.sin:
return {
expr: `Math.sin(${_wrapLeft(x, PREC.MUL)} % 360 * (Math.PI / 180))`,
prec: PREC.ATOM,
};
case TOKEN.cos:
return {
expr: `Math.cos(${_wrapLeft(x, PREC.MUL)} % 360 * (Math.PI / 180))`,
prec: PREC.ATOM,
};
case TOKEN.ln:
return { expr: `Math.log(${x.expr})`, prec: PREC.ATOM };
case TOKEN.log:
return { expr: `Math.log10(${x.expr})`, prec: PREC.ATOM };
case TOKEN.cvi:
return { expr: `(Math.trunc(${x.expr}) | 0)`, prec: PREC.ATOM };
case TOKEN.not:
if (valueType === PS_VALUE_TYPE.boolean) {
return {
expr: `(${_wrapLeft(x, PREC.EQ)} === 0 ? 1 : 0)`,
prec: PREC.ATOM,
};
}
if (valueType === PS_VALUE_TYPE.numeric) {
return {
expr: `~(${_wrapLeft(x, PREC.BOR)} | 0)`,
prec: PREC.UNARY,
};
}
return null;
default:
return null;
}
}
function _binaryToExpr(node, argNames, cseMap) {
const { op, first, second } = node;
if (op === TOKEN.bitshift) {
if (first.type !== PS_NODE.const || !Number.isInteger(first.value)) {
return null;
}
const s = _nodeToExpr(second, argNames, cseMap);
if (s === null) {
return null;
}
const amt = first.value;
const base = `(${_wrapLeft(s, PREC.BOR)} | 0)`;
if (amt > 0) {
return { expr: `${base} << ${amt}`, prec: PREC.SHIFT };
}
if (amt < 0) {
return { expr: `${base} >> ${-amt}`, prec: PREC.SHIFT };
}
return { expr: base, prec: PREC.ATOM };
}
// second is left operand (below on stack), first is right (top).
const a = _nodeToExpr(second, argNames, cseMap);
const b = _nodeToExpr(first, argNames, cseMap);
if (a === null || b === null) {
return null;
}
switch (op) {
case TOKEN.add:
return {
expr: `${_wrapLeft(a, PREC.ADD)} + ${_wrapLeft(b, PREC.ADD)}`,
prec: PREC.ADD,
};
case TOKEN.sub:
return {
expr: `${_wrapLeft(a, PREC.ADD)} - ${_wrapRight(b, PREC.ADD)}`,
prec: PREC.ADD,
};
case TOKEN.mul:
return {
expr: `${_wrapLeft(a, PREC.MUL)} * ${_wrapLeft(b, PREC.MUL)}`,
prec: PREC.MUL,
};
case TOKEN.div:
return {
expr:
`(${b.expr} !== 0 ? ` +
`${_wrapLeft(a, PREC.MUL)} / ${_wrapRight(b, PREC.MUL)} : 0)`,
prec: PREC.ATOM,
};
case TOKEN.idiv:
return {
expr:
`(${b.expr} !== 0 ? ` +
`Math.trunc(${_wrapLeft(a, PREC.MUL)} / ${_wrapRight(b, PREC.MUL)}) : 0)`,
prec: PREC.ATOM,
};
case TOKEN.mod:
return {
expr:
`(${b.expr} !== 0 ? ` +
`${_wrapLeft(a, PREC.MUL)} % ${_wrapRight(b, PREC.MUL)} : 0)`,
prec: PREC.ATOM,
};
case TOKEN.exp:
return {
expr: `${_wrapRight(a, PREC.POW)} ** ${_wrapLeft(b, PREC.POW)}`,
prec: PREC.POW,
};
case TOKEN.eq:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} === ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.ne:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} !== ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.gt:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} > ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.ge:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} >= ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.lt:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} < ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.le:
return {
expr: `(${_wrapLeft(a, PREC.CMP)} <= ${_wrapLeft(b, PREC.CMP)} ? 1 : 0)`,
prec: PREC.ATOM,
};
case TOKEN.and:
return {
expr: `(${_wrapLeft(a, PREC.BOR)} | 0) & (${_wrapLeft(b, PREC.BOR)} | 0)`,
prec: PREC.BAND,
};
case TOKEN.or:
return {
expr: `(${_wrapLeft(a, PREC.BOR)} | 0) | (${_wrapLeft(b, PREC.BOR)} | 0)`,
prec: PREC.BOR,
};
case TOKEN.xor:
return {
expr: `(${_wrapLeft(a, PREC.BOR)} | 0) ^ (${_wrapLeft(b, PREC.BOR)} | 0)`,
prec: PREC.BXOR,
};
case TOKEN.atan:
// atan2 result in degrees [0, 360).
return {
expr: `(Math.atan2(${a.expr}, ${b.expr}) * (180 / Math.PI) + 360) % 360`,
prec: PREC.MUL,
};
case TOKEN.min:
return { expr: `Math.min(${a.expr}, ${b.expr})`, prec: PREC.ATOM };
case TOKEN.max:
return { expr: `Math.max(${a.expr}, ${b.expr})`, prec: PREC.ATOM };
default:
return null;
}
}
function _ternaryToExpr(node, argNames, cseMap) {
const cond = _nodeToExpr(node.cond, argNames, cseMap);
const then = _nodeToExpr(node.then, argNames, cseMap);
const otherwise = _nodeToExpr(node.otherwise, argNames, cseMap);
if (cond === null || then === null || otherwise === null) {
return null;
}
return {
expr:
`(${_wrapLeft(cond, PREC.EQ)} !== 0 ? ` +
`${_wrapLeft(then, PREC.TERNARY)} : ${_wrapLeft(otherwise, PREC.TERNARY)})`,
prec: PREC.ATOM,
};
}
const InternalViewerUtils = {
tokenizeStream(stream, xref) {
const tokens = [];
@ -125,6 +388,130 @@ const InternalViewerUtils = {
return { instructions, cmdNames };
},
// Tokenize a PS Type 4 source into display lines: each line groups args with
// the operator that consumes them; braces get their own indented lines.
tokenizePSSource(source) {
const lexer = new PsLexer(source);
const lines = [];
let indent = 0;
let buffer = [];
const flush = () => {
if (buffer.length > 0) {
lines.push({ indent, tokens: buffer });
buffer = [];
}
};
while (true) {
const tok = lexer.next();
if (tok.id === TOKEN.eof) {
break;
}
if (tok.id === TOKEN.lbrace) {
flush();
lines.push({ indent, tokens: [{ type: "brace", value: "{" }] });
indent++;
} else if (tok.id === TOKEN.rbrace) {
flush();
indent = Math.max(0, indent - 1);
lines.push({ indent, tokens: [{ type: "brace", value: "}" }] });
} else if (tok.id === TOKEN.number) {
buffer.push({ type: "number", value: tok.value });
} else if (tok.id === TOKEN.true) {
buffer.push({ type: "boolean", value: true });
} else if (tok.id === TOKEN.false) {
buffer.push({ type: "boolean", value: false });
} else if (tok.value !== null) {
buffer.push({ type: "cmd", value: tok.value });
flush();
}
}
flush();
return lines;
},
postScriptToJSCode(source, domain, range) {
const program = parsePostScriptFunction(source);
const nIn = domain.length >> 1;
const nOut = range.length >> 1;
const outputs = new PSStackToTree().evaluate(program, nIn);
if (!outputs || outputs.length < nOut) {
return null;
}
// Named input variables: single input → "x", multiple → "x0", "x1", …
const argNames =
nIn === 1 ? ["x"] : Array.from({ length: nIn }, (_, i) => `x${i}`);
// Build cseMap in topological order using shared marks from the AST.
const cseMap = new Map();
const tmpDecls = [];
let tmpIdx = 0;
const visited = new Set();
const ensureShared = node => {
if (
!node ||
node.type === PS_NODE.arg ||
node.type === PS_NODE.const ||
visited.has(node)
) {
return;
}
visited.add(node);
switch (node.type) {
case PS_NODE.unary:
ensureShared(node.operand);
break;
case PS_NODE.binary:
ensureShared(node.first);
ensureShared(node.second);
break;
case PS_NODE.ternary:
ensureShared(node.cond);
ensureShared(node.then);
ensureShared(node.otherwise);
break;
}
if (node.shared) {
const result = _nodeToExpr(node, argNames, cseMap);
if (result !== null) {
const name = `t${tmpIdx++}`;
cseMap.set(node, name);
tmpDecls.push(` const ${name} = ${result.expr};`);
}
}
};
for (let i = 0; i < nOut; i++) {
ensureShared(outputs[i]);
}
const decls = argNames.map(
(name, i) => ` const ${name} = src[srcOffset + ${i}];`
);
const assignments = [];
for (let i = 0; i < nOut; i++) {
const result = _nodeToExpr(outputs[i], argNames, cseMap);
if (result === null) {
return null;
}
const min = range[i * 2];
const max = range[i * 2 + 1];
assignments.push(
` dest[destOffset + ${i}] = ` +
`Math.max(Math.min(${result.expr}, ${max}), ${min});`
);
}
const lines = [...decls, ""];
if (tmpDecls.length > 0) {
lines.push(...tmpDecls, "");
}
lines.push(...assignments);
return `(src, srcOffset, dest, destOffset) => {\n${lines.join("\n")}\n}`;
},
tokenToJSObject(obj) {
if (obj instanceof Cmd) {
return { type: "cmd", value: obj.cmd };

View File

@ -664,7 +664,7 @@ class PSStackToTree {
for (const [node, count] of refCount) {
if (count > 1) {
node.shared = true;
node.sharedCount = count; // remaining-use tracking in backends
node.sharedCount = count;
}
}
}

View File

@ -0,0 +1,550 @@
/* 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";
// Consecutive integers for a dense jump table in _execute.
// 2-slot: ARG, CONST, IF, JUMP, SHIFT. 4-slot: STORE. All others: 1 slot.
const OP = {
ARG: 0, // [ARG, idx]
CONST: 1, // [CONST, val]
STORE: 2, // [STORE, slot, min, max] clamp(pop()) → mem[slot]
IF: 3, // [IF, target] jump when top-of-stack === 0
JUMP: 4, // [JUMP, target] unconditional
ABS: 5,
NEG: 6,
CEIL: 7,
FLOOR: 8,
ROUND: 9, // floor(x + 0.5)
TRUNC: 10,
NOT_B: 11, // boolean NOT
NOT_N: 12, // bitwise NOT
SQRT: 13,
SIN: 14, // degrees in/out
COS: 15,
LN: 16,
LOG10: 17,
CVI: 18,
SHIFT: 19, // [SHIFT, amount] +ve = left, ve = right
// Binary ops: second below, first on top; result = second OP first.
ADD: 20,
SUB: 21,
MUL: 22,
DIV: 23, // 0 when divisor is 0
IDIV: 24, // 0 when divisor is 0
MOD: 25, // 0 when divisor is 0
POW: 26,
EQ: 27,
NE: 28,
GT: 29,
GE: 30,
LT: 31,
LE: 32,
AND: 33,
OR: 34,
XOR: 35,
ATAN: 36, // atan2(second, first) → degrees [0, 360)
MIN: 37,
MAX: 38,
TEE_TMP: 39, // [TEE_TMP, slot] peek top of stack → tmp[slot], leave on stack
LOAD_TMP: 40, // [LOAD_TMP, slot] push tmp[slot]
};
const _DEG_TO_RAD = Math.PI / 180;
const _RAD_TO_DEG = 180 / Math.PI;
class PsJsCompiler {
// Safe because JS is single-threaded.
static #stack = new Float64Array(64);
static #tmp = new Float64Array(64);
constructor(domain, range) {
this.nIn = domain.length >> 1;
this.nOut = range.length >> 1;
this.range = range;
this.ir = [];
this._tmpMap = new Map(); // node → tmp slot index (CSE)
this._nextTmp = 0;
}
_compileNode(node) {
if (node.shared) {
const cached = this._tmpMap.get(node);
if (cached !== undefined) {
this.ir.push(OP.LOAD_TMP, cached);
return true;
}
if (!this._compileNodeImpl(node)) {
return false;
}
const slot = this._nextTmp++;
this._tmpMap.set(node, slot);
this.ir.push(OP.TEE_TMP, slot);
return true;
}
return this._compileNodeImpl(node);
}
_compileNodeImpl(node) {
switch (node.type) {
case PS_NODE.arg:
this.ir.push(OP.ARG, node.index);
return true;
case PS_NODE.const: {
const v = node.value;
this.ir.push(OP.CONST, typeof v === "boolean" ? Number(v) : v);
return true;
}
case PS_NODE.unary:
return this._compileUnary(node);
case PS_NODE.binary:
return this._compileBinary(node);
case PS_NODE.ternary:
return this._compileTernary(node);
default:
return false;
}
}
_compileUnary(node) {
const { op, operand, valueType } = node;
// cvr is a no-op — values are already f64.
if (op === TOKEN.cvr) {
return this._compileNode(operand);
}
if (!this._compileNode(operand)) {
return false;
}
switch (op) {
case TOKEN.abs:
this.ir.push(OP.ABS);
break;
case TOKEN.neg:
this.ir.push(OP.NEG);
break;
case TOKEN.ceiling:
this.ir.push(OP.CEIL);
break;
case TOKEN.floor:
this.ir.push(OP.FLOOR);
break;
case TOKEN.round:
this.ir.push(OP.ROUND);
break;
case TOKEN.truncate:
this.ir.push(OP.TRUNC);
break;
case TOKEN.sqrt:
this.ir.push(OP.SQRT);
break;
case TOKEN.sin:
this.ir.push(OP.SIN);
break;
case TOKEN.cos:
this.ir.push(OP.COS);
break;
case TOKEN.ln:
this.ir.push(OP.LN);
break;
case TOKEN.log:
this.ir.push(OP.LOG10);
break;
case TOKEN.cvi:
this.ir.push(OP.CVI);
break;
case TOKEN.not:
if (valueType === PS_VALUE_TYPE.boolean) {
this.ir.push(OP.NOT_B);
} else if (valueType === PS_VALUE_TYPE.numeric) {
this.ir.push(OP.NOT_N);
} else {
return false;
}
break;
default:
return false;
}
return true;
}
_compileBinary(node) {
const { op, first, second } = node;
// bitshift requires a constant shift amount.
if (op === TOKEN.bitshift) {
if (first.type !== PS_NODE.const || !Number.isInteger(first.value)) {
return false;
}
if (!this._compileNode(second)) {
return false;
}
this.ir.push(OP.SHIFT, first.value);
return true;
}
if (!this._compileNode(second)) {
return false;
}
if (!this._compileNode(first)) {
return false;
}
switch (op) {
case TOKEN.add:
this.ir.push(OP.ADD);
break;
case TOKEN.sub:
this.ir.push(OP.SUB);
break;
case TOKEN.mul:
this.ir.push(OP.MUL);
break;
case TOKEN.div:
this.ir.push(OP.DIV);
break;
case TOKEN.idiv:
this.ir.push(OP.IDIV);
break;
case TOKEN.mod:
this.ir.push(OP.MOD);
break;
case TOKEN.exp:
this.ir.push(OP.POW);
break;
case TOKEN.eq:
this.ir.push(OP.EQ);
break;
case TOKEN.ne:
this.ir.push(OP.NE);
break;
case TOKEN.gt:
this.ir.push(OP.GT);
break;
case TOKEN.ge:
this.ir.push(OP.GE);
break;
case TOKEN.lt:
this.ir.push(OP.LT);
break;
case TOKEN.le:
this.ir.push(OP.LE);
break;
case TOKEN.and:
this.ir.push(OP.AND);
break;
case TOKEN.or:
this.ir.push(OP.OR);
break;
case TOKEN.xor:
this.ir.push(OP.XOR);
break;
case TOKEN.atan:
this.ir.push(OP.ATAN);
break;
case TOKEN.min:
this.ir.push(OP.MIN);
break;
case TOKEN.max:
this.ir.push(OP.MAX);
break;
default:
return false;
}
return true;
}
_compileTernary(node) {
if (!this._compileNode(node.cond)) {
return false;
}
this.ir.push(OP.IF, 0);
const ifPatch = this.ir.length - 1;
if (!this._compileNode(node.then)) {
return false;
}
this.ir.push(OP.JUMP, 0);
const jumpPatch = this.ir.length - 1;
this.ir[ifPatch] = this.ir.length; // IF jumps here on false
if (!this._compileNode(node.otherwise)) {
return false;
}
this.ir[jumpPatch] = this.ir.length; // JUMP lands here
return true;
}
compile(program) {
const outputs = new PSStackToTree().evaluate(program, this.nIn);
if (!outputs || outputs.length < this.nOut) {
return null;
}
for (let i = 0; i < this.nOut; i++) {
if (!this._compileNode(outputs[i])) {
return null;
}
const min = this.range[i * 2];
const max = this.range[i * 2 + 1];
this.ir.push(OP.STORE, i, min, max);
}
return new Float64Array(this.ir);
}
static execute(ir, src, srcOffset, dest, destOffset) {
let ip = 0,
sp = 0;
const n = ir.length;
const stack = PsJsCompiler.#stack;
const tmp = PsJsCompiler.#tmp;
while (ip < n) {
switch (ir[ip++] | 0) {
case OP.ARG:
stack[sp++] = src[srcOffset + (ir[ip++] | 0)];
break;
case OP.CONST:
stack[sp++] = ir[ip++];
break;
case OP.STORE: {
const slot = ir[ip++] | 0;
const min = ir[ip++];
const max = ir[ip++];
dest[destOffset + slot] = Math.max(Math.min(stack[--sp], max), min);
break;
}
case OP.IF: {
const tgt = ir[ip++];
if (stack[--sp] === 0) {
ip = tgt;
}
break;
}
case OP.JUMP:
ip = ir[ip];
break;
case OP.ABS:
stack[sp - 1] = Math.abs(stack[sp - 1]);
break;
case OP.NEG:
stack[sp - 1] = -stack[sp - 1];
break;
case OP.CEIL:
stack[sp - 1] = Math.ceil(stack[sp - 1]);
break;
case OP.FLOOR:
stack[sp - 1] = Math.floor(stack[sp - 1]);
break;
case OP.ROUND:
stack[sp - 1] = Math.floor(stack[sp - 1] + 0.5);
break;
case OP.TRUNC:
stack[sp - 1] = Math.trunc(stack[sp - 1]);
break;
case OP.NOT_B:
stack[sp - 1] = stack[sp - 1] !== 0 ? 0 : 1;
break;
case OP.NOT_N:
stack[sp - 1] = ~(stack[sp - 1] | 0);
break;
case OP.SQRT:
stack[sp - 1] = Math.sqrt(stack[sp - 1]);
break;
case OP.SIN:
stack[sp - 1] = Math.sin((stack[sp - 1] % 360) * _DEG_TO_RAD);
break;
case OP.COS:
stack[sp - 1] = Math.cos((stack[sp - 1] % 360) * _DEG_TO_RAD);
break;
case OP.LN:
stack[sp - 1] = Math.log(stack[sp - 1]);
break;
case OP.LOG10:
stack[sp - 1] = Math.log10(stack[sp - 1]);
break;
case OP.CVI:
stack[sp - 1] = Math.trunc(stack[sp - 1]) | 0;
break;
case OP.SHIFT: {
const amt = ir[ip++];
const v = stack[sp - 1] | 0;
if (amt > 0) {
stack[sp - 1] = v << amt;
} else if (amt < 0) {
stack[sp - 1] = v >> -amt;
} else {
stack[sp - 1] = v;
}
break;
}
case OP.ADD: {
const b = stack[--sp];
stack[sp - 1] += b;
break;
}
case OP.SUB: {
const b = stack[--sp];
stack[sp - 1] -= b;
break;
}
case OP.MUL: {
const b = stack[--sp];
stack[sp - 1] *= b;
break;
}
case OP.DIV: {
const b = stack[--sp];
stack[sp - 1] = b !== 0 ? stack[sp - 1] / b : 0;
break;
}
case OP.IDIV: {
const b = stack[--sp];
stack[sp - 1] = b !== 0 ? Math.trunc(stack[sp - 1] / b) : 0;
break;
}
case OP.MOD: {
const b = stack[--sp];
stack[sp - 1] = b !== 0 ? stack[sp - 1] % b : 0;
break;
}
case OP.POW: {
const b = stack[--sp];
stack[sp - 1] **= b;
break;
}
case OP.EQ: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] === b ? 1 : 0;
break;
}
case OP.NE: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] !== b ? 1 : 0;
break;
}
case OP.GT: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] > b ? 1 : 0;
break;
}
case OP.GE: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] >= b ? 1 : 0;
break;
}
case OP.LT: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] < b ? 1 : 0;
break;
}
case OP.LE: {
const b = stack[--sp];
stack[sp - 1] = stack[sp - 1] <= b ? 1 : 0;
break;
}
case OP.AND: {
const b = stack[--sp] | 0;
stack[sp - 1] = (stack[sp - 1] | 0) & b;
break;
}
case OP.OR: {
const b = stack[--sp] | 0;
stack[sp - 1] = stack[sp - 1] | 0 | b;
break;
}
case OP.XOR: {
const b = stack[--sp] | 0;
stack[sp - 1] = (stack[sp - 1] | 0) ^ b;
break;
}
case OP.ATAN: {
const b = stack[--sp];
const deg = Math.atan2(stack[sp - 1], b) * _RAD_TO_DEG;
stack[sp - 1] = deg < 0 ? deg + 360 : deg;
break;
}
case OP.MIN: {
const b = stack[--sp];
stack[sp - 1] = Math.min(stack[sp - 1], b);
break;
}
case OP.MAX: {
const b = stack[--sp];
stack[sp - 1] = Math.max(stack[sp - 1], b);
break;
}
case OP.TEE_TMP:
tmp[ir[ip++] | 0] = stack[sp - 1];
break;
case OP.LOAD_TMP:
stack[sp++] = tmp[ir[ip++] | 0];
break;
}
}
}
}
/**
* @param {string} source
* @param {number[]} domain flat [min0,max0, ]
* @param {number[]} range flat [min0,max0, ]
* @returns {Float64Array|null}
*/
function compilePostScriptToIR(source, domain, range) {
return new PsJsCompiler(domain, range).compile(
parsePostScriptFunction(source)
);
}
/**
* Same calling convention as the Wasm wrapper:
* fn(src, srcOffset, dest, destOffset)
*
* @param {string} source
* @param {number[]} domain flat [min0,max0, ]
* @param {number[]} range flat [min0,max0, ]
* @returns {Function|null}
*/
function buildPostScriptJsFunction(source, domain, range) {
const ir = compilePostScriptToIR(source, domain, range);
if (!ir) {
return null;
}
return (src, srcOffset, dest, destOffset) => {
PsJsCompiler.execute(ir, src, srcOffset, dest, destOffset);
};
}
export { buildPostScriptJsFunction, compilePostScriptToIR };

View File

@ -138,13 +138,8 @@ 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.
// Walks each PSStackToTree output node and emits Wasm, leaving one f64 per
// output on the Wasm operand stack. Ternary nodes compile to if/else/end.
class PsWasmCompiler {
static #initialized = false;
@ -257,11 +252,12 @@ class PsWasmCompiler {
this._nOut = range.length >> 1;
this._range = range;
this._code = [];
// Params 0..nIn-1 are automatically locals; extras start at _nextLocal.
// Params 0..nIn-1 are locals; extra locals start at _nextLocal.
this._nextLocal = this._nIn;
this._freeLocals = [];
// node → {local, remaining} for shared sub-expression caching (CSE).
this._sharedLocals = new Map();
this._sharedLocals = new Map(); // node → {local, remaining} for CSE
}
// Wasm emit helpers

View File

@ -35,6 +35,7 @@ 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;
@ -195,15 +196,23 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
* 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) {
const wasmFn = buildPostScriptWasmFunction(src, domain, range);
const jsFn = buildPostScriptJsFunction(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 dest = new Float64Array(nOut);
fn(srcBuf, 0, dest, 0);
return nOut === 1 ? dest[0] : Array.from(dest);
const wasmDest = new Float64Array(nOut);
const jsDest = new Float64Array(nOut);
wasmFn(srcBuf, 0, wasmDest, 0);
jsFn(srcBuf, 0, jsDest, 0);
for (let i = 0; i < nOut; i++) {
expect(jsDest[i]).toBeCloseTo(wasmDest[i], 10);
}
return nOut === 1 ? wasmDest[0] : Array.from(wasmDest);
}
function readULEB128(bytes, offset) {
@ -257,186 +266,161 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// Arithmetic.
it("compiles add", async function () {
const r = await compileAndRun(
"{ add }",
[0, 1, 0, 1],
[0, 2],
[0.3, 0.7]
);
const r = 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]
);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]
);
const r = 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]
);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r1 = compileAndRun("{ round }", [-2, 2], [-2, 2], [0.5]);
expect(r1).toBe(1);
const r2 = await compileAndRun("{ round }", [-2, 2], [-2, 2], [-0.5]);
const r2 = 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]);
const r = 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]);
const r = 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]);
const r = 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]
);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = compileAndRun("{ cos }", [-360, 360], [-1, 1], [0]);
expect(r).toBeCloseTo(1, TRIGONOMETRY_EPS);
});
@ -452,18 +436,13 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
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]
);
const r = 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(
const r = compileAndRun(
"{ atan }",
[-10, 10, -10, 10],
[0, 360],
@ -475,33 +454,23 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// Stack operators.
it("compiles dup", async function () {
const r = await compileAndRun("{ dup mul }", [0, 1], [0, 1], [0.5]);
const r = 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]
);
const r = 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]
);
const r = 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]);
const r = compileAndRun("{ 1 copy add }", [0, 1], [0, 2], [0.4]);
expect(r).toBeCloseTo(0.8, 9);
});
@ -592,7 +561,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("compiles 3-input function", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ add add }",
[0, 1, 0, 1, 0, 1],
[0, 3],
@ -602,7 +571,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("compiles 4-input function", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ add add add }",
[0, 1, 0, 1, 0, 1, 0, 1],
[0, 4],
@ -612,7 +581,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("compiles 5-input function (default caller path)", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ add add add add }",
[0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
[0, 5],
@ -624,38 +593,38 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// Comparison / boolean.
it("compiles eq", async function () {
const r = await compileAndRun("{ eq }", [0, 1, 0, 1], [0, 1], [0.5, 0.5]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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]);
const r = 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], []);
const t = compileAndRun("{ true }", [], [0, 1], []);
const f = compileAndRun("{ false }", [], [0, 1], []);
expect(t).toBeCloseTo(1, 9);
expect(f).toBeCloseTo(0, 9);
});
@ -663,7 +632,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// Conditionals.
it("compiles ifelse — true branch taken", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ dup 0.5 gt { 2 mul } { 0.5 mul } ifelse }",
[0, 1],
[0, 2],
@ -673,7 +642,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("compiles ifelse — false branch taken", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ dup 0.5 gt { 2 mul } { 0.5 mul } ifelse }",
[0, 1],
[0, 2],
@ -684,7 +653,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
it("compiles if — condition true", async function () {
// { dup 1 gt { pop 1 } if } — clamp x to 1 from above
const r = await compileAndRun(
const r = compileAndRun(
"{ dup 1 gt { pop 1 } if }",
[0, 2],
[0, 2],
@ -694,7 +663,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("compiles if — condition false", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ dup 1 gt { pop 1 } if }",
[0, 2],
[0, 2],
@ -733,29 +702,19 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
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]
);
const r = 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]);
const r = 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]
);
const r = compileAndRun("{ -2 bitshift }", [-256, 256], [-256, 256], [8]);
expect(r).toBeCloseTo(2, 9); // 8 >> 2
});
@ -764,7 +723,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// b |= 0x80 branch in _emitULEB128.
// Wasm i32.shl uses shift % 32, so 128 % 32 = 0 →
// left-shift by 0 = identity.
const r = await compileAndRun(
const r = compileAndRun(
"{ 128 bitshift }",
[-1000, 1000],
[-1000, 1000],
@ -779,7 +738,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// 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]);
const r = compileAndRun(src, [0, 1], [0, 14], [0]);
expect(r).toBeCloseTo(13, 9);
});
@ -807,13 +766,13 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
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]);
const r = 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]);
const r = compileAndRun("{ not }", [-256, 256], [-256, 256], [5]);
expect(r).toBeCloseTo(-6, 9);
});
@ -821,7 +780,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
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(
const r = compileAndRun(
"{ 0.5 gt { 1 } { 0 } ifelse }",
[0, 1],
[0, 1],
@ -831,7 +790,7 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
});
it("ifelse with comparison condition (false branch)", async function () {
const r = await compileAndRun(
const r = compileAndRun(
"{ 0.5 gt { 1 } { 0 } ifelse }",
[0, 1],
[0, 1],
@ -846,9 +805,9 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// 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]);
const r0 = 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]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.2]);
expect(r1).toBeCloseTo(0, 9); // 0.2 outside range
});
@ -856,9 +815,9 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", 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]);
const r0 = 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]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.2]);
expect(r1).toBeCloseTo(1, 9); // 0.2 outside range → not → true
});
@ -868,18 +827,18 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", 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]);
const r0 = compileAndRun(src, [0, 1], [0, 1], [0.5]);
expect(r0).toBeCloseTo(1, 9);
const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.2]);
const r1 = 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]);
const r0 = compileAndRun(src, [0, 1], [0, 1], [0.2]);
expect(r0).toBeCloseTo(1, 9);
const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.5]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.5]);
expect(r1).toBeCloseTo(0, 9);
});
@ -888,9 +847,9 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// _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]);
const r0 = 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]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.2]);
expect(r1).toBeCloseTo(1, 9); // outside → and=false → not=true
});
@ -899,11 +858,11 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// 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]);
const r0 = compileAndRun(src, [0, 1], [0, 1], [0.1]);
expect(r0).toBeCloseTo(0, 9);
const r1 = await compileAndRun(src, [0, 1], [0, 1], [0.9]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.9]);
expect(r1).toBeCloseTo(1, 9);
const r2 = await compileAndRun(src, [0, 1], [0, 1], [0.5]);
const r2 = compileAndRun(src, [0, 1], [0, 1], [0.5]);
expect(r2).toBeCloseTo(0.5, 9);
});
@ -911,9 +870,9 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", 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]);
const r0 = 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]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.5]);
expect(r1).toBeCloseTo(0, 9); // 0.5 inside → false
});
@ -922,10 +881,10 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// _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]);
const r0 = 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]);
const r1 = compileAndRun(src, [0, 1], [0, 1], [0.2]);
expect(r1).toBeCloseTo(1, 9);
});
@ -933,19 +892,9 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", 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]
);
const r0 = 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]
);
const r1 = compileAndRun("{ { 1 } { 0 } ifelse }", [0, 1], [0, 1], [0]);
expect(r1).toBeCloseTo(0, 9); // 0 → falsy → 0
});
@ -955,34 +904,29 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", 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]);
const r = 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]
);
const r = 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]);
const r = 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]);
const r = 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]);
const r = compileAndRun("{ -1 exp }", [1, 10], [0, 1], [4]);
expect(r).toBeCloseTo(0.25, 9);
});
@ -1031,19 +975,19 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
// 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]);
const r = 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]);
const r = 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(
const r = compileAndRun(
"{ dup 0.8 lt { pop 0.8 } { } ifelse " +
"dup 0.5 gt { pop 0.5 } { } ifelse }",
[0, 1],
@ -1055,14 +999,14 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
it("min/max fold: upper clamp emits f64.min", async function () {
// x > 1 → clamp to 1; x ≤ 1 → pass through.
const r1 = await compileAndRun(
const r1 = compileAndRun(
"{ dup 1 gt { pop 1 } { } ifelse }",
[0, 2],
[0, 2],
[2]
);
expect(r1).toBeCloseTo(1, 9);
const r2 = await compileAndRun(
const r2 = compileAndRun(
"{ dup 1 gt { pop 1 } { } ifelse }",
[0, 2],
[0, 2],
@ -1073,14 +1017,14 @@ describe("PostScript Type 4 lexer, parser, and Wasm compiler", function () {
it("min/max fold: lower clamp emits f64.max", async function () {
// x < 0 → clamp to 0; x ≥ 0 → pass through.
const r1 = await compileAndRun(
const r1 = compileAndRun(
"{ dup 0 lt { pop 0 } { } ifelse }",
[-1, 1],
[0, 1],
[-0.5]
);
expect(r1).toBeCloseTo(0, 9);
const r2 = await compileAndRun(
const r2 = compileAndRun(
"{ dup 0 lt { pop 0 } { } ifelse }",
[-1, 1],
[0, 1],

View File

@ -157,6 +157,7 @@
font-size: 0.8em;
color: var(--muted-color);
user-select: none;
height: 22px;
}
.mlc-num-item.mlc-match {

View File

@ -179,6 +179,7 @@
display: block;
white-space: nowrap;
padding-inline-start: 0.5em;
height: 22px;
}
.raw-bytes-stream {

View File

@ -235,6 +235,32 @@ class TreeView {
* into the ref's children container, avoiding an extra toggle level).
*/
#buildChildren(value, doc, container) {
if (this.#isPSFunction(value)) {
for (const [k, v] of Object.entries(value.dict)) {
container.append(this.#renderNode(k, v, doc));
}
const srcNode = this.#makeNodeEl("source");
const srcLabel = `[PostScript, ${value.psLines.length} lines]`;
const srcLabelEl = this.#makeSpan("stream-label", srcLabel);
srcNode.append(
this.#makeExpandable(srcLabelEl, srcLabel, c =>
this.#buildPSFunctionPanel(value, c, srcLabelEl)
)
);
container.append(srcNode);
if (value.jsCode !== null) {
const jsNode = this.#makeNodeEl("js");
const jsLabel = "[JS equivalent]";
const jsLabelEl = this.#makeSpan("stream-label", jsLabel);
jsNode.append(
this.#makeExpandable(jsLabelEl, jsLabel, c =>
this.#buildJSCodePanel(value.jsCode, c)
)
);
container.append(jsNode);
}
return;
}
if (this.#isStream(value)) {
for (const [k, v] of Object.entries(value.dict)) {
container.append(this.#renderNode(k, v, doc));
@ -310,6 +336,8 @@ class TreeView {
span.append(this.#makeSpan("bracket", "]"));
return span;
}
case "brace":
return this.#makeSpan("bracket", token.value);
case "dict": {
const span = document.createElement("span");
span.className = "token-dict";
@ -355,6 +383,8 @@ class TreeView {
return String(token.value);
case "null":
return "null";
case "brace":
return token.value;
case "array":
return `[ ${token.value.map(t => this.#tokenToText(t)).join(" ")} ]`;
case "dict": {
@ -470,6 +500,96 @@ class TreeView {
return btn;
}
// Fills container with a PostScript source panel (indented, token-coloured).
#buildPSSourcePanel(psLines, container, actions = null) {
const mc = new MultilineView({
total: psLines.length,
lineClass: "content-stream ps-source-stream",
getText: i => {
const { tokens } = psLines[i];
return tokens.map(t => this.#tokenToText(t)).join(" ");
},
actions,
makeLineEl: (i, isHighlighted) => {
const line = document.createElement("div");
line.className = "content-stm-instruction";
if (isHighlighted) {
line.classList.add("mlc-match");
}
const content = document.createElement("span");
const { indent, tokens } = psLines[i];
if (indent > 0) {
content.style.paddingInlineStart = `${indent * 1.5}em`;
}
for (let j = 0; j < tokens.length; j++) {
if (j > 0) {
content.append(document.createTextNode(" "));
}
content.append(this.#renderToken(tokens[j]));
}
line.append(content);
return line;
},
});
container.append(mc.element);
return mc;
}
// Fills container with a JS code panel (plain monospace lines).
#buildJSCodePanel(jsCode, container, actions = null) {
const lines = jsCode.split("\n");
while (lines.at(-1) === "") {
lines.pop();
}
const mc = new MultilineView({
total: lines.length,
lineClass: "content-stream js-code-stream",
getText: i => lines[i],
actions,
makeLineEl: (i, isHighlighted) => {
const el = document.createElement("div");
el.className = "content-stm-instruction";
if (isHighlighted) {
el.classList.add("mlc-match");
}
el.append(document.createTextNode(lines[i]));
return el;
},
});
container.append(mc.element);
return mc;
}
// PS source panel with parsed/raw toggle and an expandable JS equivalent.
#buildPSFunctionPanel(val, container, labelEl = null) {
let isParsed = true;
let currentPanel = null;
const rawLines = val.source.split(/\r?\n|\r/);
if (rawLines.at(-1) === "") {
rawLines.pop();
}
const parsedLabel = `[PostScript, ${val.psLines.length} lines]`;
const rawLabel = `[PostScript, ${rawLines.length} raw lines]`;
const rebuild = () => {
currentPanel?.destroy();
currentPanel = null;
container.replaceChildren();
if (labelEl) {
labelEl.textContent = isParsed ? parsedLabel : rawLabel;
}
const btn = this.#makeParseToggleBtn(isParsed, () => {
isParsed = !isParsed;
rebuild();
});
currentPanel = isParsed
? this.#buildPSSourcePanel(val.psLines, container, btn)
: this.#buildRawBytesPanel(val.source, container, btn);
};
rebuild();
}
// Fills container with the content stream panel (parsed or raw), with a
// toggle button in the toolbar that swaps the view in-place.
#buildContentStreamPanel(val, container, labelEl = null) {
@ -538,6 +658,15 @@ class TreeView {
return this.#renderContentStream(value);
}
// PostScript Type 4 function stream
if (this.#isPSFunction(value)) {
return this.#renderExpandable(
"[PostScript Function]",
"stream-label",
container => this.#buildChildren(value, doc, container)
);
}
// Stream → expandable showing dict entries + byte count or image preview
if (this.#isStream(value)) {
return this.#renderExpandable("[Stream]", "stream-label", container =>
@ -841,6 +970,17 @@ class TreeView {
#isFormXObjectStream(val) {
return this.#isStream(val) && val.contentStream === true;
}
// PostScript Type 4 function: { dict, psFunction: true, psLines, jsCode }.
#isPSFunction(val) {
return (
val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.prototype.hasOwnProperty.call(val, "dict") &&
val.psFunction === true
);
}
}
export { TreeView };