From 9f3de1edf6253e9bf6a919d149ef6485e1e31d10 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 30 Mar 2026 20:00:00 +0200 Subject: [PATCH] Add an interpreter for optimized ps code It'll be used as a fallback when wasm is disabled. And add in the debugger a view for the generated js code and one for the ps code. --- src/core/document.js | 16 + src/core/function.js | 14 +- src/core/internal_viewer_utils.js | 387 +++++++++++++++++++ src/core/postscript/ast.js | 2 +- src/core/postscript/js_evaluator.js | 550 +++++++++++++++++++++++++++ src/core/postscript/wasm_compiler.js | 16 +- test/unit/postscript_spec.js | 266 +++++-------- web/internal/multiline_view.css | 1 + web/internal/tree_view.css | 1 + web/internal/tree_view.js | 140 +++++++ 10 files changed, 1217 insertions(+), 176 deletions(-) create mode 100644 src/core/postscript/js_evaluator.js diff --git a/src/core/document.js b/src/core/document.js index 5777c998e..f91a9f29b 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -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; } diff --git a/src/core/function.js b/src/core/function.js index b00e60670..2bfa150d6 100644 --- a/src/core/function.js +++ b/src/core/function.js @@ -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"); diff --git a/src/core/internal_viewer_utils.js b/src/core/internal_viewer_utils.js index 0b5cd4cef..dcb48db7a 100644 --- a/src/core/internal_viewer_utils.js +++ b/src/core/internal_viewer_utils.js @@ -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 }; diff --git a/src/core/postscript/ast.js b/src/core/postscript/ast.js index 086005d5d..9bff1d4db 100644 --- a/src/core/postscript/ast.js +++ b/src/core/postscript/ast.js @@ -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; } } } diff --git a/src/core/postscript/js_evaluator.js b/src/core/postscript/js_evaluator.js new file mode 100644 index 000000000..8bcd77c20 --- /dev/null +++ b/src/core/postscript/js_evaluator.js @@ -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 }; diff --git a/src/core/postscript/wasm_compiler.js b/src/core/postscript/wasm_compiler.js index 32e59d50f..b68d0a217 100644 --- a/src/core/postscript/wasm_compiler.js +++ b/src/core/postscript/wasm_compiler.js @@ -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 diff --git a/test/unit/postscript_spec.js b/test/unit/postscript_spec.js index dafc90394..569a3ad23 100644 --- a/test/unit/postscript_spec.js +++ b/test/unit/postscript_spec.js @@ -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], diff --git a/web/internal/multiline_view.css b/web/internal/multiline_view.css index 9a8dc67ab..d40befc75 100644 --- a/web/internal/multiline_view.css +++ b/web/internal/multiline_view.css @@ -157,6 +157,7 @@ font-size: 0.8em; color: var(--muted-color); user-select: none; + height: 22px; } .mlc-num-item.mlc-match { diff --git a/web/internal/tree_view.css b/web/internal/tree_view.css index 1198a66f1..d9fc07469 100644 --- a/web/internal/tree_view.css +++ b/web/internal/tree_view.css @@ -179,6 +179,7 @@ display: block; white-space: nowrap; padding-inline-start: 0.5em; + height: 22px; } .raw-bytes-stream { diff --git a/web/internal/tree_view.js b/web/internal/tree_view.js index 605fed98d..4302d4885 100644 --- a/web/internal/tree_view.js +++ b/web/internal/tree_view.js @@ -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 };