From 26feec69cbd68896ac6180814078ff311da32501 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 00:37:04 +0000 Subject: [PATCH] Add reusable PostScript VM stack operators --- crates/eps/src/postscript.rs | 178 +++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/postscript.rs b/crates/eps/src/postscript.rs index 1b93c6663..a52fd92f5 100644 --- a/crates/eps/src/postscript.rs +++ b/crates/eps/src/postscript.rs @@ -1,12 +1,40 @@ -//! 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 { + None, + Popped(Option), + Count(usize), + Copied(bool), +} + /// A bounded PostScript operand stack. /// /// The stack is deliberately generic: an interpreter owns the concrete PostScript values and @@ -34,6 +62,28 @@ impl OperandStack { 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 { + 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 { + 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 { @@ -60,6 +110,12 @@ impl OperandStack { } } + /// 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(); @@ -68,6 +124,15 @@ impl OperandStack { } } + /// 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 @@ -80,16 +145,24 @@ impl OperandStack { } } + /// 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: isize) { + pub fn roll(&mut self, count: usize, shift: i64) { let len = self.values.len(); if count == 0 || count > len { return; } - let places = shift.rem_euclid(count as isize) as usize; + let places = (i128::from(shift).rem_euclid(count as i128)) as usize; self.values[len - count..].rotate_right(places); } @@ -109,6 +182,36 @@ impl OperandStack { 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 { + 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. @@ -147,6 +250,13 @@ impl ExecutionBudget { 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 { @@ -440,7 +550,7 @@ fn parse_number(word: &[u8]) -> Option { #[cfg(test)] mod stack_tests { - use super::{ExecutionBudget, OperandStack}; + use super::{ExecutionBudget, OperandStack, StackOperator, StackOutcome}; #[test] fn stack_operators_follow_postscript_order_and_respect_limit() { @@ -469,13 +579,62 @@ mod stack_tests { fn stack_underflow_and_invalid_rolls_are_safe() { let mut stack = OperandStack::new(2); assert_eq!(stack.pop_or(99), 99); - stack.roll(1, isize::MAX); + 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_eq!(stack.values(), &[]); assert!(stack.copy_top(0)); assert_eq!(stack.values(), &[]); } + #[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); @@ -483,6 +642,9 @@ mod stack_tests { 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());