mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge pull request #1236 from dylanmtaylor/codex/eps-postscript-vm
Add reusable PostScript VM stack operators
This commit is contained in:
1 file changed
+376
-4
@@ -1,12 +1,277 @@
|
||||
//! A bounded-token PostScript scanner for EPS programs.
|
||||
//! Shared PostScript format primitives for EPS programs.
|
||||
//!
|
||||
//! This module only turns source bytes into values. It does not execute operators or interpret
|
||||
//! graphics state. That boundary lets an application keep its own drawing and font policies while
|
||||
//! sharing the format-level lexical rules.
|
||||
//! [`Scanner`] handles bounded lexical parsing. [`OperandStack`] and [`ExecutionBudget`] provide
|
||||
//! reusable language-level VM behavior while leaving values, dictionaries, graphics state,
|
||||
//! rendering, and font policy to the host interpreter.
|
||||
|
||||
/// Maximum nesting depth for executable procedures in a source token.
|
||||
pub const MAX_PROCEDURE_DEPTH: usize = 100;
|
||||
|
||||
/// Maximum number of operand values an interpreter should retain by default.
|
||||
pub const MAX_OPERAND_STACK: usize = 100_000;
|
||||
|
||||
/// Maximum nested executable procedure calls supported by the bounded VM.
|
||||
pub const MAX_CALL_DEPTH: usize = 256;
|
||||
|
||||
/// PostScript operand-stack operators whose semantics do not depend on the value model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StackOperator {
|
||||
Pop,
|
||||
Exchange,
|
||||
Duplicate,
|
||||
CopyTop(usize),
|
||||
Index(usize),
|
||||
Roll { count: usize, shift: i64 },
|
||||
Clear,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// Result of applying an operand-stack operator.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum StackOutcome<T> {
|
||||
None,
|
||||
Popped(Option<T>),
|
||||
Count(usize),
|
||||
Copied(bool),
|
||||
}
|
||||
|
||||
/// A bounded PostScript operand stack.
|
||||
///
|
||||
/// The stack is deliberately generic: an interpreter owns the concrete PostScript values and
|
||||
/// graphics state, while this module provides the shared language-level stack operators. A push
|
||||
/// past `limit` is ignored, matching the bounded EPS interpreter's defensive behavior.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OperandStack<T> {
|
||||
values: Vec<T>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> OperandStack<T> {
|
||||
/// Create an empty stack with a maximum number of values.
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self { values: Vec::new(), limit }
|
||||
}
|
||||
|
||||
/// Number of values currently on the stack.
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
/// Whether the stack is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
|
||||
/// Borrow all stack values mutably from bottom to top.
|
||||
pub fn values_mut(&mut self) -> &mut [T] {
|
||||
&mut self.values
|
||||
}
|
||||
|
||||
/// Remove and return the top value, if present.
|
||||
pub fn pop(&mut self) -> Option<T> {
|
||||
self.values.pop()
|
||||
}
|
||||
|
||||
/// Remove every value.
|
||||
pub fn clear(&mut self) {
|
||||
self.values.clear();
|
||||
}
|
||||
|
||||
/// Remove values at and above `at`, returning them in bottom-to-top order. An index beyond
|
||||
/// the stack length removes nothing.
|
||||
pub fn split_off(&mut self, at: usize) -> Vec<T> {
|
||||
let at = at.min(self.values.len());
|
||||
self.values.split_off(at)
|
||||
}
|
||||
|
||||
/// Push a value if the configured limit allows it; report whether it was pushed.
|
||||
pub fn push(&mut self, value: T) -> bool {
|
||||
if self.values.len() >= self.limit {
|
||||
return false;
|
||||
}
|
||||
self.values.push(value);
|
||||
true
|
||||
}
|
||||
|
||||
/// Pop a value, or return the caller's language-specific underflow value.
|
||||
pub fn pop_or(&mut self, underflow: T) -> T {
|
||||
self.values.pop().unwrap_or(underflow)
|
||||
}
|
||||
|
||||
/// Borrow the top value.
|
||||
pub fn top(&self) -> Option<&T> {
|
||||
self.values.last()
|
||||
}
|
||||
|
||||
/// PostScript `dup`: duplicate the top value if one exists.
|
||||
pub fn duplicate(&mut self) {
|
||||
if let Some(value) = self.values.last().cloned() {
|
||||
let _ = self.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// PostScript `dup` with a caller-supplied underflow value.
|
||||
pub fn duplicate_or(&mut self, underflow: T) {
|
||||
let value = self.values.last().cloned().unwrap_or(underflow);
|
||||
let _ = self.push(value);
|
||||
}
|
||||
|
||||
/// PostScript `exch`: exchange the top two values when both exist.
|
||||
pub fn exchange(&mut self) {
|
||||
let len = self.values.len();
|
||||
if len >= 2 {
|
||||
self.values.swap(len - 1, len - 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// PostScript `exch` with caller-supplied underflow values. Both operands are popped and
|
||||
/// restored even if either pop underflows, matching interpreters that use a null sentinel.
|
||||
pub fn exchange_or(&mut self, underflow: T) {
|
||||
let b = self.pop_or(underflow.clone());
|
||||
let a = self.pop_or(underflow);
|
||||
let _ = self.push(b);
|
||||
let _ = self.push(a);
|
||||
}
|
||||
|
||||
/// PostScript `index`: copy the value `depth` below the top onto the stack.
|
||||
///
|
||||
/// Invalid or negative depths are left to the caller to validate; an out-of-range depth is a
|
||||
/// no-op. This keeps the helper bounded even for hostile numeric operands.
|
||||
pub fn index(&mut self, depth: usize) {
|
||||
let len = self.values.len();
|
||||
if depth < len && len < self.limit {
|
||||
let value = self.values[len - depth - 1].clone();
|
||||
self.values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// PostScript `index` with a caller-supplied underflow value, pushed even when `depth` is out
|
||||
/// of range. The configured stack limit still applies.
|
||||
pub fn index_or(&mut self, depth: usize, underflow: T) {
|
||||
let len = self.values.len();
|
||||
let value = if depth < len { self.values[len - depth - 1].clone() } else { underflow };
|
||||
let _ = self.push(value);
|
||||
}
|
||||
|
||||
/// PostScript `n j roll`: rotate the top `count` values by signed `shift` places.
|
||||
///
|
||||
/// Positive shifts move values toward the top; negative shifts move them toward the bottom.
|
||||
/// Counts larger than the stack and empty rolls are ignored.
|
||||
pub fn roll(&mut self, count: usize, shift: i64) {
|
||||
let len = self.values.len();
|
||||
if count == 0 || count > len {
|
||||
return;
|
||||
}
|
||||
let places = (i128::from(shift).rem_euclid(count as i128)) as usize;
|
||||
self.values[len - count..].rotate_right(places);
|
||||
}
|
||||
|
||||
/// PostScript `copy` for an integer operand: append a copy of the top `count` values if it
|
||||
/// fits. Returns false when the range is invalid or the configured limit would be exceeded.
|
||||
pub fn copy_top(&mut self, count: usize) -> bool {
|
||||
let len = self.values.len();
|
||||
if count > len || len.saturating_add(count) > self.limit {
|
||||
return false;
|
||||
}
|
||||
let copied = self.values[len - count..].to_vec();
|
||||
self.values.extend(copied);
|
||||
true
|
||||
}
|
||||
|
||||
/// Borrow all stack values from bottom to top.
|
||||
pub fn values(&self) -> &[T] {
|
||||
&self.values
|
||||
}
|
||||
|
||||
/// Apply a PostScript operand-stack operator. `underflow` is the interpreter's fallback value
|
||||
/// for `exch`, `dup`, and `index`; the stack's configured limit remains in force.
|
||||
pub fn execute(&mut self, operator: StackOperator, underflow: T) -> StackOutcome<T> {
|
||||
match operator {
|
||||
StackOperator::Pop => StackOutcome::Popped(self.pop()),
|
||||
StackOperator::Exchange => {
|
||||
self.exchange_or(underflow);
|
||||
StackOutcome::None
|
||||
}
|
||||
StackOperator::Duplicate => {
|
||||
self.duplicate_or(underflow);
|
||||
StackOutcome::None
|
||||
}
|
||||
StackOperator::CopyTop(count) => StackOutcome::Copied(self.copy_top(count)),
|
||||
StackOperator::Index(depth) => {
|
||||
self.index_or(depth, underflow);
|
||||
StackOutcome::None
|
||||
}
|
||||
StackOperator::Roll { count, shift } => {
|
||||
self.roll(count, shift);
|
||||
StackOutcome::None
|
||||
}
|
||||
StackOperator::Clear => {
|
||||
self.clear();
|
||||
StackOutcome::None
|
||||
}
|
||||
StackOperator::Count => StackOutcome::Count(self.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded execution accounting shared by PostScript interpreters.
|
||||
///
|
||||
/// EPS programs can recurse or repeatedly execute procedures. A host can use this counter to
|
||||
/// stop work before it consumes unbounded CPU, independently of its operator and graphics policy.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutionBudget {
|
||||
remaining: usize,
|
||||
call_depth: usize,
|
||||
max_call_depth: usize,
|
||||
}
|
||||
|
||||
impl ExecutionBudget {
|
||||
/// Create a budget with an operation allowance and maximum nested procedure calls.
|
||||
pub fn new(operations: usize, max_call_depth: usize) -> Self {
|
||||
Self { remaining: operations, call_depth: 0, max_call_depth }
|
||||
}
|
||||
|
||||
/// Units of work still available.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.remaining
|
||||
}
|
||||
|
||||
/// Current nested procedure depth.
|
||||
pub fn call_depth(&self) -> usize {
|
||||
self.call_depth
|
||||
}
|
||||
|
||||
/// Charge work atomically. On failure the remaining budget stays unchanged.
|
||||
pub fn spend(&mut self, units: usize) -> bool {
|
||||
let Some(remaining) = self.remaining.checked_sub(units) else {
|
||||
return false;
|
||||
};
|
||||
self.remaining = remaining;
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge work that is accounted lazily: saturate at zero so the caller's next strict
|
||||
/// [`Self::spend`] stops execution. This supports work whose current operator preserves the
|
||||
/// app's historical behavior after exhausting its budget.
|
||||
pub fn charge(&mut self, units: usize) {
|
||||
self.remaining = self.remaining.saturating_sub(units);
|
||||
}
|
||||
|
||||
/// Enter a procedure call if it remains within the configured depth.
|
||||
pub fn enter_call(&mut self) -> bool {
|
||||
if self.call_depth >= self.max_call_depth {
|
||||
return false;
|
||||
}
|
||||
self.call_depth += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Leave a procedure call. Extra leaves are ignored to keep cleanup paths safe.
|
||||
pub fn leave_call(&mut self) {
|
||||
self.call_depth = self.call_depth.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A lexical PostScript value. Names are left unevaluated for the caller's interpreter.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Token {
|
||||
@@ -283,6 +548,113 @@ fn parse_number(word: &[u8]) -> Option<f64> {
|
||||
.or_else(|| text.trim_start_matches(['+', '-']).parse::<f64>().ok().map(|value| if text.starts_with('-') { -value } else { value }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stack_tests {
|
||||
use super::{ExecutionBudget, OperandStack, StackOperator, StackOutcome};
|
||||
|
||||
#[test]
|
||||
fn stack_operators_follow_postscript_order_and_respect_limit() {
|
||||
let mut stack = OperandStack::new(8);
|
||||
assert!(stack.push(1));
|
||||
assert!(stack.push(2));
|
||||
assert!(stack.push(3));
|
||||
stack.exchange();
|
||||
assert_eq!(stack.values(), &[1, 3, 2]);
|
||||
stack.duplicate();
|
||||
assert_eq!(stack.values(), &[1, 3, 2, 2]);
|
||||
stack.roll(4, 1);
|
||||
assert_eq!(stack.values(), &[2, 1, 3, 2]);
|
||||
stack.roll(4, -1);
|
||||
assert_eq!(stack.values(), &[1, 3, 2, 2]);
|
||||
stack.index(2);
|
||||
assert_eq!(stack.values(), &[1, 3, 2, 2, 3]);
|
||||
assert!(stack.copy_top(2));
|
||||
assert_eq!(stack.values(), &[1, 3, 2, 2, 3, 2, 3]);
|
||||
stack.duplicate();
|
||||
assert_eq!(stack.len(), 8);
|
||||
assert!(!stack.push(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_underflow_and_invalid_rolls_are_safe() {
|
||||
let mut stack = OperandStack::new(2);
|
||||
assert_eq!(stack.pop_or(99), 99);
|
||||
stack.duplicate_or(99);
|
||||
assert_eq!(stack.values(), &[99]);
|
||||
stack.clear();
|
||||
stack.exchange_or(99);
|
||||
assert_eq!(stack.values(), &[99, 99]);
|
||||
stack.clear();
|
||||
stack.roll(1, i64::MAX);
|
||||
stack.index(usize::MAX);
|
||||
assert!(stack.is_empty());
|
||||
assert!(stack.copy_top(0));
|
||||
assert!(stack.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_mutation_and_underflow_index_are_bounded() {
|
||||
let mut stack = OperandStack::new(4);
|
||||
assert!(stack.push(10));
|
||||
assert!(stack.push(20));
|
||||
stack.index_or(1, -1);
|
||||
stack.index_or(9, -1);
|
||||
assert_eq!(stack.values(), &[10, 20, 10, -1]);
|
||||
stack.index_or(0, -1);
|
||||
assert_eq!(stack.values(), &[10, 20, 10, -1]); // full stack silently drops the push
|
||||
|
||||
assert_eq!(stack.top(), Some(&-1));
|
||||
stack.values_mut()[3] = 40;
|
||||
stack.roll(3, i64::MIN);
|
||||
assert_eq!(stack.values(), &[10, 40, 20, 10]);
|
||||
assert!(stack.split_off(usize::MAX).is_empty());
|
||||
assert_eq!(stack.split_off(1), [40, 20, 10]);
|
||||
assert_eq!(stack.values(), &[10]);
|
||||
assert_eq!(stack.pop(), Some(10));
|
||||
assert_eq!(stack.pop(), None);
|
||||
stack.clear();
|
||||
assert!(stack.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_stack_operator_dispatch_reports_only_value_results() {
|
||||
let mut stack = OperandStack::new(4);
|
||||
assert!(stack.push(1));
|
||||
assert!(stack.push(2));
|
||||
assert_eq!(stack.execute(StackOperator::Count, 0), StackOutcome::Count(2));
|
||||
assert_eq!(stack.execute(StackOperator::Exchange, 0), StackOutcome::None);
|
||||
assert_eq!(stack.values(), &[2, 1]);
|
||||
assert_eq!(stack.execute(StackOperator::Duplicate, 0), StackOutcome::None);
|
||||
assert_eq!(stack.execute(StackOperator::Index(2), 0), StackOutcome::None);
|
||||
assert_eq!(stack.values(), &[2, 1, 1, 2]);
|
||||
assert_eq!(stack.execute(StackOperator::Roll { count: 3, shift: 1 }, 0), StackOutcome::None);
|
||||
assert_eq!(stack.values(), &[2, 2, 1, 1]);
|
||||
assert_eq!(stack.execute(StackOperator::CopyTop(2), 0), StackOutcome::Copied(false));
|
||||
assert_eq!(stack.execute(StackOperator::Pop, 0), StackOutcome::Popped(Some(1)));
|
||||
assert_eq!(stack.execute(StackOperator::Clear, 0), StackOutcome::None);
|
||||
assert!(stack.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_budget_charges_atomically_and_bounds_call_depth() {
|
||||
let mut budget = ExecutionBudget::new(3, 1);
|
||||
assert!(budget.spend(2));
|
||||
assert_eq!(budget.remaining(), 1);
|
||||
assert!(!budget.spend(2));
|
||||
assert_eq!(budget.remaining(), 1);
|
||||
budget.charge(2);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(!budget.spend(1));
|
||||
assert!(budget.enter_call());
|
||||
assert_eq!(budget.call_depth(), 1);
|
||||
assert!(!budget.enter_call());
|
||||
budget.leave_call();
|
||||
budget.leave_call();
|
||||
assert_eq!(budget.call_depth(), 0);
|
||||
assert!(budget.enter_call());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Scanner, Token};
|
||||
|
||||
Reference in new issue
Block a user