Skip to main content

stet_core/
context.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Execution context: stacks, storage, operator table, and state.
6
7use std::io::Write;
8
9use crate::device::OutputDevice;
10use crate::dict::DictKey;
11use crate::display_list::{DisplayList, GroupParams, SoftMaskParams};
12use crate::dual_array_store::DualArrayStore;
13use crate::dual_dict_store::DualDictStore;
14use crate::dual_string_store::DualStringStore;
15use crate::error::PsError;
16use crate::file_store::FileStore;
17use crate::graphics_state::{GraphicsState, Matrix, PathSegment, PatternData, PsPath};
18use crate::name::NameTable;
19use crate::object::{EntityId, NameId, ObjFlags, PsObject, PsValue, SaveLevel};
20use crate::save_stack::{SaveRecord, SaveStack, StoreType};
21use crate::stack::Stack;
22
23/// Operator table entry: function pointer + name.
24pub struct OpEntry {
25    pub func: fn(&mut Context) -> Result<(), PsError>,
26    pub name: NameId,
27}
28
29/// Pre-interned `NameId`s for frequently-used names in hot paths.
30pub struct NameCache {
31    pub n_def: NameId,
32    pub n_true: NameId,
33    pub n_false: NameId,
34    pub n_null: NameId,
35    pub n_mark: NameId,
36    // Font-related names
37    pub n_font_name: NameId,
38    pub n_font_type: NameId,
39    pub n_font_matrix: NameId,
40    pub n_font_bbox: NameId,
41    pub n_encoding: NameId,
42    pub n_char_strings: NameId,
43    pub n_private: NameId,
44    pub n_fid: NameId,
45    pub n_paint_type: NameId,
46    pub n_subrs: NameId,
47    pub n_len_iv: NameId,
48    pub n_notdef: NameId,
49    pub n_metrics: NameId,
50    pub n_font_directory: NameId,
51    // Resource system names
52    pub n_find_resource: NameId,
53    pub n_define_resource: NameId,
54    pub n_undef_resource: NameId,
55    pub n_resource_status: NameId,
56    pub n_resource_for_all: NameId,
57    pub n_category: NameId,
58    pub n_instance_type: NameId,
59    pub n_resource_dir: NameId,
60    pub n_resource_ext: NameId,
61    // Type 3 font names
62    pub n_build_char: NameId,
63    pub n_build_glyph: NameId,
64    // PaintType 2 / WMode support
65    pub n_stroke_width: NameId,
66    pub n_wmode: NameId,
67}
68
69/// Loop state for `for`, `repeat`, `loop`, and `forall`.
70pub struct LoopState {
71    pub loop_type: LoopType,
72    pub proc_entity: EntityId,
73    pub proc_start: u32,
74    pub proc_len: u32,
75
76    // for/repeat state
77    pub counter: f64,
78    pub increment: f64,
79    pub limit: f64,
80    pub use_int: bool,
81
82    // forall state
83    pub source: PsObject,
84    pub index: u32,
85    /// Snapshot of dict keys for dict forall (avoids re-collecting every iteration).
86    pub dict_keys: Option<Vec<DictKey>>,
87
88    // pathforall state
89    pub path_segments: Option<Vec<PathSegment>>,
90    pub path_procs: Option<[PsObject; 4]>, // [move, line, curve, close]
91    pub path_ictm: Option<Matrix>,
92}
93
94/// Type of loop iteration.
95pub enum LoopType {
96    For,
97    Repeat,
98    Loop,
99    Forall,
100    PathForall,
101}
102
103/// Function pointer type for synchronous procedure execution.
104/// Set by the engine crate to enable inline PS procedure calls from operators.
105pub type ExecSyncFn = fn(&mut Context, PsObject) -> Result<(), PsError>;
106
107/// Key for [`Context::cie_decode_cache`].
108///
109/// The two leading words are a 128-bit structural fingerprint of the decode
110/// procedure (including whatever the dict stack currently binds its
111/// executable names to); the remainder is the sample count and the endpoints
112/// of the sampled range, as raw bits so the key stays hashable.
113pub type CieDecodeKey = (u64, u64, u32, u64, u64);
114
115pub struct Context {
116    // Stacks
117    pub o_stack: Stack,
118    pub e_stack: Stack,
119    pub d_stack: Vec<EntityId>,
120
121    // Storage
122    pub strings: DualStringStore,
123    pub arrays: DualArrayStore,
124    pub dicts: DualDictStore,
125    pub names: NameTable,
126    pub files: FileStore,
127
128    // Loop state storage (indexed by EntityId)
129    pub loops: Vec<LoopState>,
130
131    // Operator table
132    pub operators: Vec<OpEntry>,
133
134    // Well-known dict IDs
135    pub systemdict: EntityId,
136    pub globaldict: EntityId,
137    pub userdict: EntityId,
138    pub errordict: EntityId,
139    pub dollar_error: EntityId,
140
141    // State
142    pub rand_state: u64,
143    /// Last value handed to `srand`, returned by `rrand`. Widened with
144    /// [`PsValue::Int`]: `rrand` must return exactly what `srand` was given.
145    pub rand_seed: i64,
146    /// Current source line number (1-based), updated during scanning.
147    pub current_source_line: u32,
148    /// Packing mode for array/procedure creation (setpacking/currentpacking).
149    pub packing_mode: bool,
150    /// Echo mode for %lineedit/%statementedit (PLRM echo operator).
151    pub echo: bool,
152
153    // Pre-interned names
154    pub name_cache: NameCache,
155
156    // Output: writer for print/= operators (allows capture in tests)
157    pub stdout: Box<dyn Write>,
158
159    // VM save/restore
160    pub save_stack: SaveStack,
161    /// Save stack depth when the current job started (for startjob condition 3).
162    pub job_start_save_depth: usize,
163
164    // VM allocation mode: true = global, false = local
165    pub vm_alloc_mode: bool,
166
167    /// Binary object format (0-4). Default 0.
168    pub object_format: i32,
169
170    // Error dispatch state
171    pub current_operator: Option<NameId>,
172    /// Whether `nulldevice` was installed at any point during this job.
173    ///
174    /// Sticky for the job, and deliberately not cleared by a `grestore` that
175    /// puts a real device back: it records intent, not current state. A
176    /// program that asked for the null device said it wants no output, so
177    /// marks it leaves unemitted at end of job are expected rather than a
178    /// dropped page worth reporting. The PS test suite is the motivating
179    /// case — its files paint into `gsave nulldevice ... grestore` to
180    /// exercise operators, never call `showpage`, and must not be nagged
181    /// about it.
182    pub null_device_used: bool,
183    pub in_error_handler: bool,
184    /// True during init script execution — relaxes access checks.
185    pub initializing: bool,
186    /// When true, PS programs can change HWResolution via setpagedevice.
187    /// Set by WASM frontend; CLI leaves false to keep DPI under user control.
188    pub allow_ps_resolution: bool,
189
190    /// Process exit code requested by the running PS program via the
191    /// `.quitwithcode` operator. `None` means "use the default" (0 on
192    /// success). The CLI reads this on `PsError::Quit` and propagates
193    /// to `std::process::exit`.
194    pub exit_code: Option<i32>,
195
196    // Graphics state
197    pub gstate: GraphicsState,
198    pub gstate_stack: Vec<crate::graphics_state::GstateEntry>,
199    /// Storage for gstate objects (PsValue::Gstate indexes into this).
200    pub gstate_store: Vec<GraphicsState>,
201    pub device: Option<Box<dyn OutputDevice>>,
202    pub display_list: DisplayList,
203    /// Stack of active transparency-group capture frames. While non-empty,
204    /// paint operators emit into the topmost frame's display list instead
205    /// of `display_list`. `endtransparencygroup` pops the top frame and
206    /// emits a [`stet_graphics::display_list::DisplayElement::Group`] into
207    /// the next-innermost target. See `op_begintransparencygroup` /
208    /// `op_endtransparencygroup` in `stet-ops::transparency_ops`.
209    pub group_stack: Vec<GroupFrame>,
210    /// `group_stack.len()` recorded at each `save`. `restore` consults
211    /// this to refuse a revert that would unwind across an unbalanced
212    /// `begintransparencygroup` / `endtransparencygroup` pair.
213    pub save_group_depths: rustc_hash::FxHashMap<u32, usize>,
214    /// Registry of OCGs (PDF Optional Content Groups) declared via the
215    /// `defineocg` operator. Keyed by the interned `NameId` of the
216    /// human-readable layer name from the OCG dict's `/Name` entry.
217    /// `beginoptionalcontent` looks up an OCG by name to obtain the
218    /// `ocg_id` and `default_visible` it embeds into the emitted
219    /// [`stet_graphics::display_list::OcgVisibility::Single`].
220    pub ocg_registry: rustc_hash::FxHashMap<NameId, OcgRecord>,
221    /// Monotonic counter feeding [`OcgRecord::ocg_id`]. Each call to
222    /// `defineocg` increments this; ids never recycle.
223    pub next_ocg_id: u32,
224    /// Document-level structural data — outline, annotations, metadata,
225    /// page boxes, etc. — parallel IR to [`display_list`](Self::display_list).
226    /// PostScript `pdfmark` operators populate this; the PDF output device
227    /// drains it at end-of-job; non-PDF devices ignore it. Document-global:
228    /// `save` / `restore` do not roll this back. See
229    /// [`stet_graphics::document_structure`].
230    pub doc_structure: stet_graphics::document_structure::DocumentStructure,
231    /// When `Some`, each showpage clones the display list here before consuming it.
232    /// Used by the WASM frontend to retain display lists for viewport re-rendering.
233    /// Each entry is (DisplayList, dpi) where dpi is from the pagedevice HWResolution.
234    pub capture_display_lists: Option<Vec<(DisplayList, f64)>>,
235    /// When `Some`, each showpage sends a clone of the display list through this channel.
236    /// Used by the CLI viewer for incremental display list delivery.
237    /// Tuple: `(DisplayList, dpi, page_width, page_height,
238    /// effective_cmyk_bytes, cmyk_proofing)`. PostScript pages always pass
239    /// `None`/`false` (no PDF/X concept); PDF pages may set these from the
240    /// document's OutputIntent context.
241    pub display_list_sender: Option<
242        std::sync::mpsc::Sender<(
243            DisplayList,
244            f64,
245            u32,
246            u32,
247            Option<std::sync::Arc<Vec<u8>>>,
248            bool,
249        )>,
250    >,
251    pub page_width: u32,
252    pub page_height: u32,
253    pub output_path: Option<String>,
254    /// Page filter: if set, only render pages in this set (1-based).
255    pub page_filter: Option<std::collections::HashSet<i32>>,
256    /// Factory closure for creating raster devices (registered by CLI).
257    #[allow(clippy::type_complexity)]
258    pub device_factory: Option<Box<dyn Fn(u32, u32) -> Box<dyn OutputDevice>>>,
259
260    // Font system
261    pub font_directory: EntityId,
262    pub font_resource_path: Option<String>,
263    pub next_fid: i32,
264
265    // Resource system
266    pub global_resources: EntityId,
267    pub local_resources: EntityId,
268    pub category_registry: EntityId,
269    pub resource_base_path: Option<String>,
270
271    // Parameter system
272    pub user_params: EntityId,
273    pub system_params: EntityId,
274
275    /// Backing dict for the `internaldict` operator.
276    ///
277    /// Created during bootstrap rather than on first use. `Context` holds the
278    /// `EntityId` for the whole life of the interpreter, so the dict has to
279    /// outlive every `restore`; creating it lazily inside a save bracket would
280    /// leave this handle pointing at storage that `restore` reclaims. Entries
281    /// written into it after a `save` are still reverted normally, by the
282    /// dict's own copy-on-write.
283    pub internaldict: EntityId,
284
285    // ICC color profile cache
286    pub icc_cache: crate::icc::IccCache,
287
288    // Synchronous procedure execution (set by engine crate)
289    pub exec_sync_fn: Option<ExecSyncFn>,
290
291    // Character width set by setcachedevice/setcharwidth during BuildChar execution
292    pub char_width: Option<(f64, f64)>,
293    // Mode 1 metrics from setcachedevice2: ((w1x, w1y), (vx, vy))
294    pub char_width_mode1: Option<((f64, f64), (f64, f64))>,
295
296    // Glyph path cache: per-font charstring interpretation results
297    pub glyph_caches: rustc_hash::FxHashMap<EntityId, crate::glyph_cache::GlyphCache>,
298    // Type 3 cache mode: set by setcachedevice/setcharwidth during BuildChar
299    pub char_cache_mode: Option<crate::glyph_cache::Type3CacheMode>,
300
301    // CID passed from cshow to nested show call for Type 0 composite fonts
302    pub cshow_pending_cid: Option<i32>,
303
304    /// Set while a Type 3 glyph procedure runs under `charpath`.
305    ///
306    /// PLRM: `charpath` "obtains the path for the glyph outlines that would
307    /// result if string were shown"; for a Type 3 font that means running the
308    /// glyph procedure without painting. While this is `Some`, `fill`,
309    /// `eofill` and `stroke` contribute the path they were given here instead
310    /// of marking the page — in particular `stroke` contributes the path as
311    /// constructed, since `charpath`'s own boolean operand, not the glyph
312    /// procedure, decides whether the result gets stroked.
313    pub charpath_capture: Option<PsPath>,
314
315    // Pattern/form support
316    /// Storage for pattern instances created by `makepattern`.
317    pub pattern_store: Vec<PatternData>,
318    /// Cache of form display lists keyed by dict EntityId.
319    pub form_cache: rustc_hash::FxHashMap<EntityId, DisplayList>,
320
321    /// Memo of sampled CIE decode tables, keyed by a structural fingerprint
322    /// of the decode procedure together with the sampled range.
323    ///
324    /// Sampling one table runs the procedure 256 times, and a CIE colour
325    /// space installs up to six of them, so a file that re-installs the same
326    /// space per page (what `pdftops` emits for every ICCBased space) pays
327    /// thousands of `exec_sync` calls per page. Worse, decode procedures
328    /// routinely contain inline array literals, and every evaluation
329    /// allocates a fresh array in the non-reclaiming array arena — turning
330    /// the repeated sampling into unbounded memory growth.
331    ///
332    /// Memoising is sound because the PLRM already requires a CIE decode
333    /// procedure to be a pure function of its single input: sampling it at
334    /// 256 points and interpolating (which this code has always done) is the
335    /// same assumption. See `stet_ops::color_ops::eval_decode_table_range`.
336    pub cie_decode_cache: rustc_hash::FxHashMap<CieDecodeKey, Vec<f64>>,
337
338    // Timing
339    pub start_time: Option<std::time::Instant>,
340
341    // Name resolution cache: invalidated on begin/end/def
342    pub dict_version: u64,
343    /// Name resolution cache indexed by NameId. Each entry is (dict_version, resolved_object).
344    /// Public for inline cache checks in the eval loop's hot path.
345    pub name_resolve_cache: Vec<(u64, PsObject)>,
346
347    /// When set, the eval loop aborts with `PsError::Quit` on the next iteration.
348    /// Used by the interactive viewer to cancel an in-flight parse when the
349    /// user drops a new file.
350    pub interrupt_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
351
352    /// When true, each successful `showpage` / `copypage` sets `interrupt_flag`
353    /// after capturing the display list, so the eval loop yields back to the
354    /// caller one page at a time. The caller clears the flag and re-enters
355    /// `eval` to drive the next page. Used by the WASM viewer to stream
356    /// multi-page PostScript documents: page 1 renders while pages 2..N are
357    /// still pending interpretation. Requires `interrupt_flag` to be set.
358    pub yield_after_showpage: bool,
359}
360
361/// One frame on `Context::group_stack`. Captures paint operators emitted
362/// between a `begin*` and a matching close. The active capture target is
363/// always [`Self::display_list`]; what happens to it on close depends on
364/// [`Self::kind`].
365pub struct GroupFrame {
366    /// Paint operators emitted while this frame is on top of
367    /// `group_stack`. The semantics on close depend on `kind`.
368    pub display_list: DisplayList,
369    /// What this frame represents — transparency group, soft-mask
370    /// builder, or post-`endsoftmask` masked-content scope.
371    pub kind: GroupKind,
372    /// `gstate.clip_path_version` snapshot taken when the frame opened.
373    /// Reserved for future use (e.g. detecting clip changes that
374    /// crossed the boundary).
375    pub saved_clip_path_version: u32,
376    /// `gstate_stack.len()` at the moment the frame opened. Used by
377    /// `gsave` / `grestore` and `restore` to refuse pops that would
378    /// orphan this frame.
379    pub saved_gsave_depth: usize,
380}
381
382/// What a [`GroupFrame`] is capturing, determining what gets emitted
383/// when it closes.
384pub enum GroupKind {
385    /// Opened by `begintransparencygroup`. On close, the captured
386    /// `display_list` becomes the children of a
387    /// `DisplayElement::Group` with these `params`.
388    Transparency { params: GroupParams },
389    /// Opened by `beginsoftmask`. While active, paint ops emit into the
390    /// frame's `display_list` to build the mask form. `endsoftmask`
391    /// transmutes the frame to [`Self::Masked`] without popping.
392    SoftMask { params: SoftMaskParams },
393    /// Implicitly opened by `endsoftmask`. While active, paint ops emit
394    /// into `display_list` as the *content* the mask attenuates. On
395    /// `clearsoftmask` the frame pops and emits a
396    /// `DisplayElement::SoftMasked` carrying `mask`, the captured
397    /// content, and `params`.
398    Masked {
399        mask: DisplayList,
400        params: SoftMaskParams,
401    },
402    /// Opened by `beginoptionalcontent`. On close, the captured
403    /// `display_list` becomes the children of a
404    /// `DisplayElement::OcgGroup` whose visibility is
405    /// `OcgVisibility::Single { ocg_id, default_visible }`.
406    OptionalContent { ocg_id: u32, default_visible: bool },
407}
408
409/// One entry in `Context::ocg_registry`. `defineocg` allocates these
410/// and indexes them by the OCG's interned `NameId` so
411/// `beginoptionalcontent` can resolve a name back to its `ocg_id` and
412/// the `default_visible` flag the producer set.
413#[derive(Clone, Debug)]
414pub struct OcgRecord {
415    /// Monotonic id assigned by `defineocg`. Embedded into
416    /// `OcgVisibility::Single` on the display list.
417    pub ocg_id: u32,
418    /// Initial visibility, used by the renderer when no `LayerSet`
419    /// override exists for this OCG.
420    pub default_visible: bool,
421}
422
423/// Does `data` contain a complete zlib/deflate stream?
424///
425/// Used to stop draining a procedure data source that feeds `FlateDecode` and
426/// cycles rather than ever returning the empty end-of-data string.
427fn is_flate_stream_complete(data: &[u8]) -> bool {
428    let mut decomp = flate2::Decompress::new(true);
429    let mut out = [0u8; 8192];
430    let mut pos = 0;
431    loop {
432        if pos >= data.len() {
433            return false;
434        }
435        match decomp.decompress(&data[pos..], &mut out, flate2::FlushDecompress::None) {
436            Ok(flate2::Status::StreamEnd) => return true,
437            Ok(_) => {
438                let new_pos = decomp.total_in() as usize;
439                if new_pos == pos {
440                    return false;
441                }
442                pos = new_pos;
443            }
444            Err(_) => return false,
445        }
446    }
447}
448
449impl Context {
450    /// Execute a PostScript procedure synchronously and return.
451    pub fn exec_sync(&mut self, proc_obj: PsObject) -> Result<(), PsError> {
452        let f = self.exec_sync_fn.expect("exec_sync not initialized");
453        f(self, proc_obj)
454    }
455
456    /// Run any not-yet-executed procedure data source underneath `entity`,
457    /// replacing it with the bytes it produces.
458    ///
459    /// A filter's data source may be a procedure (PLRM 3.8.4), which the
460    /// filter is supposed to call for more data as the consumer reads. Running
461    /// it when `filter` is *called* instead is observably wrong: the procedure
462    /// runs against whatever is on the operand stack at that moment. `pdftops`
463    /// builds an inline image as
464    ///
465    /// ```postscript
466    /// << /ImageType 1 ... /DataSource { pdfImStr } /LZWDecode filter >> imagemask
467    /// ```
468    ///
469    /// so `filter` is reached while the enclosing `<< ... >>` is still on the
470    /// stack, and `pdfImStr` — which reads its `array index` state off the
471    /// stack — would pick up the half-built dictionary instead.
472    ///
473    /// So the procedure is run here, from the read path, when the consumer's
474    /// operands are the ones in place. **Every entry point that reads from a
475    /// file must call this first**; see [`FileHandle::PendingProc`].
476    ///
477    /// The drain is one-shot rather than incremental: the procedure is run to
478    /// completion and the result installed as a plain byte source. That keeps
479    /// the filters themselves unchanged — none of them has to cope with a
480    /// source that is temporarily dry — while still running the procedure at
481    /// the right moment, which is what the bug above is about.
482    pub fn pump_proc_sources(&mut self, entity: EntityId) -> Result<(), PsError> {
483        // A chain can hold more than one procedure source, so loop until the
484        // walk reports none left. `pending_proc_source` short-circuits on a
485        // counter, so this costs one integer compare on the overwhelmingly
486        // common path where no procedure source exists at all.
487        while let Some((src, proc, flate_above)) = self.files.pending_proc_source(entity) {
488            let data = self.drain_proc_source(proc, flate_above)?;
489            self.files.install_proc_data(src, data);
490        }
491        Ok(())
492    }
493
494    /// Call a procedure data source until it signals end of data.
495    ///
496    /// Per PLRM the procedure pushes a string each call and an empty string
497    /// means end of data. `flate_above` additionally stops once the collected
498    /// bytes form a complete deflate stream: a procedure feeding `FlateDecode`
499    /// may cycle indefinitely rather than ever returning the empty string.
500    ///
501    /// KNOWN LIMITATION: not every procedure signals end of data at all.
502    /// `pdftops` emits paging readers of the form
503    ///
504    /// ```postscript
505    /// { dup 65535 ge { pop 1 add 0 } if 2 index 2 index get 1 index get exch 1 add exch }
506    /// ```
507    ///
508    /// which walk an array of blocks and simply run off the end — they rely on
509    /// the *consumer* to stop asking once the image has all its rows, and a
510    /// full drain has no such stopping point. Running the procedure truly on
511    /// demand, one call per refill, is what those need. That requires
512    /// `refill_filter` to be able to re-enter the interpreter, and every
513    /// `refill_*` to tell "source temporarily dry" apart from EOF so it does
514    /// not latch `eof` on the first short read.
515    fn drain_proc_source(
516        &mut self,
517        procedure: PsObject,
518        flate_above: bool,
519    ) -> Result<Vec<u8>, PsError> {
520        /// Cap on what one procedure data source may produce (64 MB).
521        const MAX_PROC_BYTES: usize = 64 * 1024 * 1024;
522
523        let mut data = Vec::new();
524        loop {
525            let depth_before = self.o_stack.len();
526            self.exec_sync(procedure)?;
527
528            if self.o_stack.len() <= depth_before {
529                break;
530            }
531            let result = self.o_stack.peek(0)?;
532            match result.value {
533                PsValue::String { entity, start, len } => {
534                    let bytes = self.strings.get(entity, start, len).to_vec();
535                    self.o_stack.pop()?;
536                    if bytes.is_empty() {
537                        break; // end of data per PLRM
538                    }
539                    data.extend_from_slice(&bytes);
540                    if flate_above && is_flate_stream_complete(&data) {
541                        break;
542                    }
543                    if data.len() >= MAX_PROC_BYTES {
544                        break;
545                    }
546                }
547                // Anything other than a string is treated as end of data.
548                _ => break,
549            }
550        }
551        Ok(data)
552    }
553
554    /// Create a new context with empty stacks and stores.
555    /// Call `build_system_dict` afterward to populate operators.
556    pub fn new() -> Self {
557        let mut names = NameTable::new();
558
559        let name_cache = NameCache {
560            n_def: names.intern(b"def"),
561            n_true: names.intern(b"true"),
562            n_false: names.intern(b"false"),
563            n_null: names.intern(b"null"),
564            n_mark: names.intern(b"mark"),
565            n_font_name: names.intern(b"FontName"),
566            n_font_type: names.intern(b"FontType"),
567            n_font_matrix: names.intern(b"FontMatrix"),
568            n_font_bbox: names.intern(b"FontBBox"),
569            n_encoding: names.intern(b"Encoding"),
570            n_char_strings: names.intern(b"CharStrings"),
571            n_private: names.intern(b"Private"),
572            n_fid: names.intern(b"FID"),
573            n_paint_type: names.intern(b"PaintType"),
574            n_subrs: names.intern(b"Subrs"),
575            n_len_iv: names.intern(b"lenIV"),
576            n_notdef: names.intern(b".notdef"),
577            n_metrics: names.intern(b"Metrics"),
578            n_font_directory: names.intern(b"FontDirectory"),
579            // Resource system
580            n_find_resource: names.intern(b"FindResource"),
581            n_define_resource: names.intern(b"DefineResource"),
582            n_undef_resource: names.intern(b"UndefineResource"),
583            n_resource_status: names.intern(b"ResourceStatus"),
584            n_resource_for_all: names.intern(b"ResourceForAll"),
585            n_category: names.intern(b"Category"),
586            n_instance_type: names.intern(b"InstanceType"),
587            n_resource_dir: names.intern(b"ResourceDir"),
588            n_resource_ext: names.intern(b"ResourceExtension"),
589            n_build_char: names.intern(b"BuildChar"),
590            n_build_glyph: names.intern(b"BuildGlyph"),
591            n_stroke_width: names.intern(b"StrokeWidth"),
592            n_wmode: names.intern(b"WMode"),
593        };
594
595        let mut strings = DualStringStore::new();
596        let mut dicts = DualDictStore::new();
597
598        // Only systemdict is pre-allocated in Rust — it's needed to register native
599        // operators. All other well-known dicts (globaldict, userdict, errordict, $error,
600        // FontDirectory) are created by the init scripts in sysdict.ps.
601        //
602        // `allocate_at_level_zero` is correct here and only here: no `save` can be
603        // outstanding during bootstrap, so `save_level = 0` / `created_after_save = 0`
604        // is the truth rather than a mis-stamp, and these entities sit below every
605        // future save's high-water mark. Everywhere else, use the VM-aware helpers in
606        // `stet_ops::vm_ops`.
607        let systemdict = dicts.allocate_with(400, b"systemdict", 0, true, 0);
608        let globaldict = dicts.allocate_with(100, b"globaldict", 0, true, 0);
609        let userdict = dicts.allocate_at_level_zero(200, b"userdict");
610        let errordict = dicts.allocate_at_level_zero(50, b"errordict");
611        let dollar_error = dicts.allocate_at_level_zero(20, b"$error");
612        let font_directory = dicts.allocate_at_level_zero(50, b"FontDirectory");
613
614        // Resource system dicts (global VM)
615        let global_resources = dicts.allocate_with(20, b"GlobalResources", 0, true, 0);
616        let local_resources = dicts.allocate_at_level_zero(20, b"LocalResources");
617        let internaldict = dicts.allocate_at_level_zero(50, b"internaldict");
618        let category_registry = dicts.allocate_with(30, b"CategoryRegistry", 0, true, 0);
619
620        // Parameter dicts — pre-populate user_params with recognized keys.
621        // setuserparams only updates existing keys; unknown keys are
622        // ignored per PLRM.
623        let user_params = dicts.allocate_at_level_zero(25, b"UserParams");
624        for key_name in [
625            "MaxDictStack",
626            "MaxExecStack",
627            "MaxOpStack",
628            "MaxFontItem",
629            "MaxFormItem",
630            "MaxPatternItem",
631            "MaxUPathItem",
632            "MaxScreenItem",
633            "MaxSuperScreen",
634            "MinFontCompress",
635            "MaxLocalVM",
636            "VMReclaim",
637            "VMThreshold",
638            "UCacheBLimit",
639        ] {
640            dicts.put(
641                user_params,
642                DictKey::Name(names.intern(key_name.as_bytes())),
643                PsObject::int(0),
644            );
645        }
646        dicts.put(
647            user_params,
648            DictKey::Name(names.intern(b"JobName")),
649            PsObject::string(strings.allocate_from_at_level_zero(b""), 0),
650        );
651        dicts.put(
652            user_params,
653            DictKey::Name(names.intern(b"ExecutionHistory")),
654            PsObject::bool(false),
655        );
656        dicts.put(
657            user_params,
658            DictKey::Name(names.intern(b"ExecutionHistorySize")),
659            PsObject::int(20),
660        );
661        dicts.put(
662            user_params,
663            DictKey::Name(names.intern(b"IdiomRecognition")),
664            PsObject::bool(true),
665        );
666        dicts.put(
667            user_params,
668            DictKey::Name(names.intern(b"AccurateScreens")),
669            PsObject::bool(false),
670        );
671        dicts.put(
672            user_params,
673            DictKey::Name(names.intern(b"HalftoneMode")),
674            PsObject::int(0),
675        );
676
677        let system_params = dicts.allocate_at_level_zero(30, b"SystemParams");
678        // Cache size limits (PLRM Table C.2 - system parameters)
679        for (key, val) in [
680            ("MaxFontCache", 67108864),
681            ("MaxFormCache", 131072),
682            ("MaxPatternCache", 131072),
683            ("MaxUPathCache", 131072),
684            ("MaxScreenStorage", 524288),
685            ("MaxDisplayList", 2097152),
686            ("MaxDisplayAndSourceList", 4194304),
687            ("MaxSourceList", 2097152),
688            ("MaxImageBuffer", 524288),
689            ("MaxOutlineCache", 65536),
690            ("MaxStoredScreenCache", 0),
691            // Read-only current cache usage counters
692            ("CurFontCache", 0),
693            ("CurFormCache", 0),
694            ("CurPatternCache", 0),
695            ("CurUPathCache", 0),
696            ("CurScreenStorage", 0),
697            ("CurSourceList", 0),
698            ("CurStoredScreenCache", 0),
699            ("CurOutlineCache", 0),
700            ("PageCount", 0),
701            ("Revision", 1),
702        ] {
703            dicts.put(
704                system_params,
705                DictKey::Name(names.intern(key.as_bytes())),
706                PsObject::int(val),
707            );
708        }
709        let printer_name = b"stet";
710        let printer_str = strings.allocate_from_at_level_zero(printer_name);
711        dicts.put(
712            system_params,
713            DictKey::Name(names.intern(b"PrinterName")),
714            PsObject::string(printer_str, printer_name.len() as u32),
715        );
716        // PLRM: RealFormat names the internal real representation.
717        let real_format = b"IEEE";
718        let realfmt_str = strings.allocate_from_at_level_zero(real_format);
719        dicts.put(
720            system_params,
721            DictKey::Name(names.intern(b"RealFormat")),
722            PsObject::string(realfmt_str, real_format.len() as u32),
723        );
724        let pw_str = strings.allocate_from_at_level_zero(b"0");
725        dicts.put(
726            system_params,
727            DictKey::Name(names.intern(b"SystemParamsPassword")),
728            PsObject::string(pw_str, 1),
729        );
730        let pw_str2 = strings.allocate_from_at_level_zero(b"0");
731        dicts.put(
732            system_params,
733            DictKey::Name(names.intern(b"StartJobPassword")),
734            PsObject::string(pw_str2, 1),
735        );
736        dicts.put(
737            system_params,
738            DictKey::Name(names.intern(b"LicenseID")),
739            PsObject::int(0),
740        );
741
742        // Put self-referencing entries
743        let sd_obj = PsObject::dict(systemdict);
744        dicts.put(
745            systemdict,
746            DictKey::Name(names.intern(b"systemdict")),
747            sd_obj,
748        );
749
750        let ud_obj = PsObject::dict(userdict);
751        dicts.put(systemdict, DictKey::Name(names.intern(b"userdict")), ud_obj);
752
753        let gd_obj = PsObject::dict(globaldict);
754        dicts.put(
755            systemdict,
756            DictKey::Name(names.intern(b"globaldict")),
757            gd_obj,
758        );
759
760        let ed_obj = PsObject::dict(errordict);
761        dicts.put(
762            systemdict,
763            DictKey::Name(names.intern(b"errordict")),
764            ed_obj,
765        );
766
767        let de_obj = PsObject::dict(dollar_error);
768        dicts.put(systemdict, DictKey::Name(names.intern(b"$error")), de_obj);
769
770        let fd_obj = PsObject::dict(font_directory);
771        dicts.put(
772            systemdict,
773            DictKey::Name(name_cache.n_font_directory),
774            fd_obj,
775        );
776
777        // Register constants in systemdict
778        dicts.put(
779            systemdict,
780            DictKey::Name(names.intern(b"true")),
781            PsObject::bool(true),
782        );
783        dicts.put(
784            systemdict,
785            DictKey::Name(names.intern(b"false")),
786            PsObject::bool(false),
787        );
788        dicts.put(
789            systemdict,
790            DictKey::Name(names.intern(b"null")),
791            PsObject::null(),
792        );
793
794        // mark — literal mark object
795        dicts.put(
796            systemdict,
797            DictKey::Name(names.intern(b"mark")),
798            PsObject::mark(),
799        );
800
801        // [ is an alias for mark
802        dicts.put(
803            systemdict,
804            DictKey::Name(names.intern(b"[")),
805            PsObject::mark(),
806        );
807
808        // << is a dict mark (distinct from [ mark so ] doesn't match it)
809        dicts.put(
810            systemdict,
811            DictKey::Name(names.intern(b"<<")),
812            PsObject::dict_mark(),
813        );
814
815        // version and languagelevel
816        dicts.put(
817            systemdict,
818            DictKey::Name(names.intern(b"languagelevel")),
819            PsObject::int(3),
820        );
821
822        // Dictionary stack: systemdict, globaldict, userdict
823        let d_stack = vec![systemdict, globaldict, userdict];
824
825        Self {
826            o_stack: Stack::new(500),
827            e_stack: Stack::new(250),
828            d_stack,
829            strings,
830            arrays: DualArrayStore::new(),
831            dicts,
832            names,
833            files: FileStore::new(),
834            loops: Vec::new(),
835            operators: Vec::new(),
836            systemdict,
837            globaldict,
838            userdict,
839            errordict,
840            dollar_error,
841            rand_state: 0,
842            rand_seed: 0,
843            current_source_line: 1,
844            packing_mode: false,
845            echo: false,
846            name_cache,
847            stdout: Box::new(std::io::stdout()),
848            save_stack: SaveStack::new(),
849            job_start_save_depth: 0,
850            vm_alloc_mode: false,
851            object_format: 0,
852            current_operator: None,
853            null_device_used: false,
854            in_error_handler: false,
855            initializing: true,
856            allow_ps_resolution: false,
857            exit_code: None,
858            gstate: GraphicsState::new(),
859            gstate_stack: Vec::new(),
860            gstate_store: Vec::new(),
861            device: None,
862            display_list: DisplayList::new(),
863            group_stack: Vec::new(),
864            save_group_depths: rustc_hash::FxHashMap::default(),
865            ocg_registry: rustc_hash::FxHashMap::default(),
866            next_ocg_id: 0,
867            doc_structure: stet_graphics::document_structure::DocumentStructure::new(),
868            capture_display_lists: None,
869            display_list_sender: None,
870            page_width: 612,
871            page_height: 792,
872            output_path: None,
873            page_filter: None,
874            device_factory: None,
875            font_directory,
876            font_resource_path: None,
877            next_fid: 0,
878            global_resources,
879            local_resources,
880            category_registry,
881            resource_base_path: None,
882            user_params,
883            system_params,
884            internaldict,
885            icc_cache: crate::icc::IccCache::new(),
886            exec_sync_fn: None,
887            char_width: None,
888            char_width_mode1: None,
889            glyph_caches: rustc_hash::FxHashMap::default(),
890            char_cache_mode: None,
891            cshow_pending_cid: None,
892            charpath_capture: None,
893            pattern_store: Vec::new(),
894            form_cache: rustc_hash::FxHashMap::default(),
895            cie_decode_cache: rustc_hash::FxHashMap::default(),
896            #[cfg(not(target_arch = "wasm32"))]
897            start_time: Some(std::time::Instant::now()),
898            #[cfg(target_arch = "wasm32")]
899            start_time: None,
900            dict_version: 0,
901            name_resolve_cache: Vec::new(),
902            interrupt_flag: None,
903            yield_after_showpage: false,
904        }
905    }
906
907    /// Create a context that captures stdout to a buffer (for testing).
908    pub fn new_with_output(output: Box<dyn Write>) -> Self {
909        let mut ctx = Self::new();
910        ctx.stdout = output;
911        ctx
912    }
913
914    // --- Dictionary stack operations ---
915
916    /// Look up a name in the dictionary stack (top to bottom).
917    #[inline]
918    pub fn dict_load(&mut self, key: &DictKey) -> Option<PsObject> {
919        // Fast path: check name resolution cache
920        if let DictKey::Name(name_id) = key {
921            let idx = name_id.0 as usize;
922            if idx < self.name_resolve_cache.len() {
923                let (ver, obj) = self.name_resolve_cache[idx];
924                if ver == self.dict_version {
925                    return Some(obj);
926                }
927            }
928        }
929
930        // Slow path: search dict stack
931        for &dict_id in self.d_stack.iter().rev() {
932            if let Some(val) = self.dicts.get(dict_id, key) {
933                // Cache the result for Name keys
934                if let DictKey::Name(name_id) = key {
935                    let idx = name_id.0 as usize;
936                    if idx >= self.name_resolve_cache.len() {
937                        self.name_resolve_cache
938                            .resize(idx + 64, (u64::MAX, PsObject::null()));
939                    }
940                    self.name_resolve_cache[idx] = (self.dict_version, val);
941                }
942                return Some(val);
943            }
944        }
945        None
946    }
947
948    /// Invalidate the name resolution cache (call on begin/end/def).
949    #[inline]
950    pub fn invalidate_name_cache(&mut self) {
951        self.dict_version = self.dict_version.wrapping_add(1);
952    }
953
954    /// Look up and return `(dict_entity, value)` pair.
955    pub fn dict_where(&self, key: &DictKey) -> Option<(EntityId, PsObject)> {
956        for &dict_id in self.d_stack.iter().rev() {
957            if let Some(val) = self.dicts.get(dict_id, key) {
958                return Some((dict_id, val));
959            }
960        }
961        None
962    }
963
964    /// Store in current dict (top of d_stack).
965    pub fn dict_def(&mut self, key: DictKey, value: PsObject) -> Result<(), PsError> {
966        let current = *self.d_stack.last().ok_or(PsError::DictStackUnderflow)?;
967        self.cow_check_dict(current);
968        self.invalidate_name_cache();
969        self.dicts.put(current, key, value);
970        Ok(())
971    }
972
973    /// Store in first dict that contains key, or current dict if not found.
974    pub fn dict_store(&mut self, key: DictKey, value: PsObject) -> Result<(), PsError> {
975        self.invalidate_name_cache();
976        for &dict_id in self.d_stack.iter().rev() {
977            if self.dicts.known(dict_id, &key) {
978                self.cow_check_dict(dict_id);
979                self.dicts.put(dict_id, key, value);
980                return Ok(());
981            }
982        }
983        // Not found — store in current dict
984        self.dict_def(key, value)
985    }
986
987    /// Convert a `PsObject` to a `DictKey`.
988    pub fn make_dict_key(&mut self, obj: &PsObject) -> Result<DictKey, PsError> {
989        match obj.value {
990            PsValue::Name(id) => Ok(DictKey::Name(id)),
991            PsValue::Int(v) => Ok(DictKey::Int(v)),
992            PsValue::Real(v) => Ok(DictKey::Real(v.to_bits())),
993            PsValue::Bool(v) => Ok(DictKey::Bool(v)),
994            PsValue::String { entity, start, len } => {
995                // Intern string as name — PostScript treats string and name
996                // keys as equivalent in dict lookups.
997                let bytes = self.strings.get(entity, start, len).to_vec();
998                let name_id = self.names.intern(&bytes);
999                Ok(DictKey::Name(name_id))
1000            }
1001            PsValue::Operator(op) => Ok(DictKey::Operator(op.0)),
1002            PsValue::Array { entity, start, len } | PsValue::PackedArray { entity, start, len } => {
1003                Ok(DictKey::Identity(entity.0, start, len))
1004            }
1005            PsValue::Dict(entity) => Ok(DictKey::Identity(entity.0, 0, 0)),
1006            PsValue::Null => Err(PsError::TypeCheck),
1007            _ => Err(PsError::TypeCheck),
1008        }
1009    }
1010
1011    /// Allocate a new loop state, returning its EntityId.
1012    pub fn alloc_loop(&mut self, state: LoopState) -> EntityId {
1013        let id = EntityId(self.loops.len() as u32);
1014        self.loops.push(state);
1015        id
1016    }
1017
1018    /// Get a loop state by EntityId.
1019    pub fn get_loop(&self, entity: EntityId) -> &LoopState {
1020        &self.loops[entity.0 as usize]
1021    }
1022
1023    /// Get a mutable loop state by EntityId.
1024    pub fn get_loop_mut(&mut self, entity: EntityId) -> &mut LoopState {
1025        &mut self.loops[entity.0 as usize]
1026    }
1027
1028    /// Return the display list paint operators should currently append to.
1029    ///
1030    /// While a transparency group is active (`group_stack` non-empty),
1031    /// the topmost frame's display list is returned. Otherwise the
1032    /// page-level `display_list` is returned. Every paint-emitting
1033    /// operator must route through this helper to keep group capture
1034    /// correct.
1035    #[inline]
1036    pub fn current_display_list_mut(&mut self) -> &mut DisplayList {
1037        if let Some(frame) = self.group_stack.last_mut() {
1038            &mut frame.display_list
1039        } else {
1040            &mut self.display_list
1041        }
1042    }
1043
1044    /// Read-only counterpart to [`Self::current_display_list_mut`].
1045    #[inline]
1046    pub fn current_display_list(&self) -> &DisplayList {
1047        if let Some(frame) = self.group_stack.last() {
1048            &frame.display_list
1049        } else {
1050            &self.display_list
1051        }
1052    }
1053
1054    /// Take the display list, optionally capturing a clone for viewport re-rendering.
1055    ///
1056    /// This replaces `std::mem::take(&mut ctx.display_list)` at showpage/copypage
1057    /// call sites. When `capture_display_lists` is active, a clone is saved
1058    /// along with the current page DPI from the pagedevice HWResolution.
1059    pub fn take_display_list(&mut self) -> DisplayList {
1060        if self.capture_display_lists.is_some() {
1061            let dpi = self.current_page_dpi();
1062            if let Some(ref mut captures) = self.capture_display_lists {
1063                captures.push((self.display_list.clone(), dpi));
1064            }
1065        }
1066        if let Some(ref sender) = self.display_list_sender {
1067            let dpi = self.current_page_dpi();
1068            // Use the device's actual page size (device pixels), not
1069            // self.page_width/page_height which are point values.
1070            let (w, h) = self
1071                .device
1072                .as_ref()
1073                .map(|d| d.page_size())
1074                .unwrap_or((self.page_width, self.page_height));
1075            // PS interpreter output: no PDF-specific CMYK profile in play, so
1076            // the viewer uses its CLI-level default. PDF/X proofing is a
1077            // PDF-only concept; PostScript always sends `false`.
1078            let _ = sender.send((self.display_list.clone(), dpi, w, h, None, false));
1079        }
1080        // Page-boundary yield: once the display list for this page has been
1081        // captured (above), signal the eval loop to return so the caller can
1082        // hand the page off to a renderer before interpreting the next one.
1083        if self.yield_after_showpage
1084            && let Some(ref flag) = self.interrupt_flag
1085        {
1086            flag.store(true, std::sync::atomic::Ordering::Relaxed);
1087        }
1088        std::mem::take(&mut self.display_list)
1089    }
1090
1091    /// Read the current page DPI from the pagedevice HWResolution, defaulting to 72.
1092    pub fn current_page_dpi(&self) -> f64 {
1093        use crate::dict::DictKey;
1094        if let Some(pd) = self.gstate.page_device
1095            && let Some(name_id) = self.names.find(b"HWResolution")
1096            && let Some(obj) = self.dicts.get(pd, &DictKey::Name(name_id))
1097            && let PsValue::Array { entity, .. } = obj.value
1098        {
1099            let first = self.arrays.get_element(entity, 0);
1100            return match first.value {
1101                PsValue::Real(r) => r,
1102                PsValue::Int(i) => i as f64,
1103                _ => 72.0,
1104            };
1105        }
1106        72.0
1107    }
1108
1109    // --- VM save/restore ---
1110
1111    /// Perform a `save`: snapshot the current VM state.
1112    /// Returns a Save PsObject.
1113    /// Current high-water marks of local VM, for reclamation on restore.
1114    fn vm_marks(&self) -> crate::save_stack::VmMarks {
1115        crate::save_stack::VmMarks {
1116            string_data: self.strings.local.data_len(),
1117            string_entities: self.strings.local.entities.len(),
1118            array_data: self.arrays.local.allocated_objects(),
1119            array_entities: self.arrays.local.entities.len(),
1120            dict_slots: self.dicts.local.dict_slots(),
1121            dict_entities: self.dicts.local.entities.len(),
1122        }
1123    }
1124
1125    pub fn vm_save(&mut self) -> PsObject {
1126        let d_depth = self.d_stack.len();
1127        let gstate_snapshot = self.gstate.clone();
1128        let gstate_stack_snapshot = self.gstate_stack.clone();
1129        let marks = self.vm_marks();
1130        let (_level, save_id) = self.save_stack.save(crate::save_stack::SaveSnapshot {
1131            d_stack_depth: d_depth,
1132            packing_mode: self.packing_mode,
1133            vm_alloc_mode: self.vm_alloc_mode,
1134            object_format: self.object_format,
1135            gstate: gstate_snapshot,
1136            gstate_stack: gstate_stack_snapshot,
1137            gstate_store_len: self.gstate_store.len(),
1138            marks,
1139        });
1140
1141        // Implicit gsave: push current gstate marked as save-created (per PLRM).
1142        // grestoreall stops at this entry; grestore skips it.
1143        self.gstate_stack.push(crate::graphics_state::GstateEntry {
1144            state: self.gstate.clone(),
1145            saved_by_save: true,
1146        });
1147
1148        PsObject {
1149            value: PsValue::Save(SaveLevel(save_id)),
1150            flags: crate::object::ObjFlags::literal(),
1151        }
1152    }
1153
1154    /// Perform a `restore`: revert VM to the given save state.
1155    pub fn vm_restore(&mut self, save_id: u32) -> Result<(), PsError> {
1156        // Validate save_id
1157        if !self.save_stack.is_valid(save_id) {
1158            return Err(PsError::InvalidRestore);
1159        }
1160
1161        // Per PLRM: "restore can reset VM to the state represented by any
1162        // save object that is still valid, not necessarily the one produced
1163        // by the most recent save."  Pop the target level AND all newer
1164        // levels, undoing COW records from newest to target.
1165        let levels = self
1166            .save_stack
1167            .restore_to(save_id)
1168            .ok_or(PsError::InvalidRestore)?;
1169
1170        // Undo COW records from newest level to oldest (reverse order).
1171        // Each level's records are also processed in reverse.
1172        // After swapping offsets, reset save_level to 0 so future COW
1173        // checks at the same save level don't incorrectly skip the backup.
1174        for level in levels.iter().rev() {
1175            for record in level.records.iter().rev() {
1176                match record.store_type {
1177                    StoreType::String => {
1178                        self.strings.swap_offsets(record.src, record.copy);
1179                        self.strings.entity_meta_mut(record.src).save_level = 0;
1180                    }
1181                    StoreType::Array => {
1182                        self.arrays.swap_offsets(record.src, record.copy);
1183                        self.arrays.entity_meta_mut(record.src).save_level = 0;
1184                    }
1185                    StoreType::Dict => {
1186                        self.dicts.swap_offsets(record.src, record.copy);
1187                        self.dicts.entity_meta_mut(record.src).save_level = 0;
1188                    }
1189                }
1190            }
1191        }
1192
1193        // Restore context parameters from the TARGET save level (first in vec)
1194        let target = &levels[0];
1195        self.packing_mode = target.packing_mode;
1196        self.vm_alloc_mode = target.vm_alloc_mode;
1197        self.object_format = target.object_format;
1198
1199        // Restore graphics state from the target level
1200        self.gstate = target.gstate.clone();
1201        self.gstate_stack = target.gstate_stack.clone();
1202
1203        // Reclaim gstate objects created after the save. `check_invalidrestore`
1204        // has already refused the restore if any of them is still reachable, so
1205        // truncating here can only drop slots nothing can name.
1206        self.gstate_store.truncate(target.gstate_store_len);
1207
1208        // Restore d_stack depth from the target level
1209        self.d_stack.truncate(target.d_stack_depth);
1210
1211        // Reclaim everything the restored levels allocated in local VM.
1212        //
1213        // Safe by the same PLRM 3.7.3.2 rule `check_invalidrestore` enforces:
1214        // nothing reachable may still refer to a composite created after the
1215        // save. The COW swaps above are what make it hold for pre-save objects
1216        // that were mutated -- `cow_copy` leaves the surviving data at its
1217        // original offset, below the mark, and parks the discarded mutated copy
1218        // above it. Global VM is untouched by save/restore, so only local
1219        // stores are truncated.
1220        let marks = target.marks;
1221
1222        // EntityIds become reusable the moment the tables shrink, so anything
1223        // keyed by one has to be dropped first -- otherwise a future entity
1224        // reusing the index would hit a stale entry belonging to a dead object.
1225        // Both caches below are keyed by dict entities.
1226        let dict_mark = marks.dict_entities;
1227        let live_dict = |e: &EntityId| e.is_global() || e.raw_index() < dict_mark;
1228        self.glyph_caches.retain(|entity, _| live_dict(entity));
1229        self.form_cache.retain(|entity, _| live_dict(entity));
1230
1231        self.strings
1232            .local
1233            .truncate_to(marks.string_data, marks.string_entities);
1234        self.arrays
1235            .local
1236            .truncate_to(marks.array_data, marks.array_entities);
1237        self.dicts
1238            .local
1239            .truncate_to(marks.dict_slots, marks.dict_entities);
1240
1241        self.invalidate_name_cache();
1242        self.close_restored_proc_sources();
1243        self.debug_assert_no_dangling_refs();
1244        Ok(())
1245    }
1246
1247    /// Close any procedure data source whose procedure the restore just
1248    /// reclaimed.
1249    ///
1250    /// `filter` may be handed a procedure and the resulting file read only
1251    /// later; if a `restore` falls in between, the procedure's array is gone.
1252    /// PLRM 3.7.3 has `restore` close files opened since the `save`, which is
1253    /// precisely the right outcome — a subsequent read reports end-of-file
1254    /// rather than following a retired entity id.
1255    fn close_restored_proc_sources(&mut self) {
1256        for (entity, proc) in self.files.pending_proc_handles() {
1257            if !self.entity_is_live(&proc) {
1258                self.files.close_pending_proc(entity);
1259            }
1260        }
1261    }
1262
1263    /// Does `obj`'s composite still exist, or did a `restore` retire it?
1264    fn entity_is_live(&self, obj: &PsObject) -> bool {
1265        match obj.value {
1266            PsValue::Array { entity, .. } | PsValue::PackedArray { entity, .. } => {
1267                let len = if entity.is_global() {
1268                    self.arrays.global.entities.len()
1269                } else {
1270                    self.arrays.local.entities.len()
1271                };
1272                entity.raw_index() < len
1273            }
1274            _ => true,
1275        }
1276    }
1277
1278    /// Panic if anything that survived a `restore` names storage the restore
1279    /// released.
1280    ///
1281    /// Reclaiming local VM means retiring entity ids, and an id that outlives
1282    /// its storage is a use-after-free that surfaces as a panic deep in an
1283    /// unrelated operator. `check_invalidrestore` is supposed to prevent it by
1284    /// raising `invalidrestore` first, but it only scans the operand,
1285    /// execution, and dictionary stacks — the graphics state, `gstate_store`,
1286    /// and the entity-keyed caches are not covered. This turns the gap from an
1287    /// argument into something every debug-build test run checks.
1288    ///
1289    /// Debug builds only: the sweep is O(size of VM), far too expensive for
1290    /// release. Run the PS suite or a corpus sweep under a debug build when
1291    /// touching anything that holds an `EntityId` across a `restore` — the
1292    /// release build compiles this out entirely.
1293    #[inline]
1294    fn debug_assert_no_dangling_refs(&self) {
1295        #[cfg(debug_assertions)]
1296        {
1297            let dangling = crate::vm_audit::audit_dangling_refs(self);
1298            assert!(
1299                dangling.is_empty(),
1300                "restore left {} dangling reference(s):\n  {}",
1301                dangling.len(),
1302                dangling
1303                    .iter()
1304                    .map(|d| d.to_string())
1305                    .collect::<Vec<_>>()
1306                    .join("\n  ")
1307            );
1308        }
1309    }
1310
1311    // --- COW check methods ---
1312
1313    /// Check if a string entity needs COW before mutation.
1314    /// If yes, creates a backup copy and records it.
1315    pub fn cow_check_string(&mut self, entity: EntityId) {
1316        let current_level = self.save_stack.current_level();
1317        if current_level == 0 {
1318            return; // No save active
1319        }
1320
1321        if entity.is_global() {
1322            return; // Global entities skip local COW
1323        }
1324        let meta = self.strings.entity_meta(entity);
1325        if meta.save_level >= current_level {
1326            return; // Already copied at this level
1327        }
1328
1329        // Perform COW copy
1330        let copy_id = self.strings.cow_copy(entity);
1331        self.strings.entity_meta_mut(entity).save_level = current_level;
1332
1333        self.save_stack.add_record(SaveRecord {
1334            src: entity,
1335            copy: copy_id,
1336            store_type: StoreType::String,
1337        });
1338    }
1339
1340    /// Check if an array entity needs COW before mutation.
1341    pub fn cow_check_array(&mut self, entity: EntityId) {
1342        let current_level = self.save_stack.current_level();
1343        if current_level == 0 {
1344            return;
1345        }
1346
1347        if entity.is_global() {
1348            return;
1349        }
1350        let meta = self.arrays.entity_meta(entity);
1351        if meta.save_level >= current_level {
1352            return;
1353        }
1354
1355        let copy_id = self.arrays.cow_copy(entity);
1356        self.arrays.entity_meta_mut(entity).save_level = current_level;
1357
1358        self.save_stack.add_record(SaveRecord {
1359            src: entity,
1360            copy: copy_id,
1361            store_type: StoreType::Array,
1362        });
1363    }
1364
1365    /// Check if a dict entity needs COW before mutation.
1366    /// Store into a dictionary, copy-on-writing it first.
1367    ///
1368    /// Prefer this over a bare `ctx.dicts.put` for any write into a dictionary
1369    /// the current operation did not itself allocate — `FontDirectory`, the
1370    /// resource dictionaries, `userdict`, a caller-supplied dict. Writing
1371    /// straight through bypasses save/restore: if the dictionary predates the
1372    /// current `save`, no backup is taken and `restore` will not revert the
1373    /// entry. That leaves the dictionary holding a value the restore released.
1374    ///
1375    /// [`cow_check_dict`](Self::cow_check_dict) is cheap and idempotent — it
1376    /// returns immediately at save level 0, for global entities, and for
1377    /// dictionaries already copied at this level — so there is no reason to
1378    /// skip it when in doubt.
1379    pub fn dict_put_cow(&mut self, entity: EntityId, key: DictKey, value: PsObject) {
1380        self.cow_check_dict(entity);
1381        self.dicts.put(entity, key, value);
1382    }
1383
1384    pub fn cow_check_dict(&mut self, entity: EntityId) {
1385        let current_level = self.save_stack.current_level();
1386        if current_level == 0 {
1387            return;
1388        }
1389
1390        if entity.is_global() {
1391            return;
1392        }
1393        let meta = self.dicts.entity_meta(entity);
1394        if meta.save_level >= current_level {
1395            return;
1396        }
1397
1398        let copy_id = self.dicts.cow_copy(entity);
1399        self.dicts.entity_meta_mut(entity).save_level = current_level;
1400
1401        self.save_stack.add_record(SaveRecord {
1402            src: entity,
1403            copy: copy_id,
1404            store_type: StoreType::Dict,
1405        });
1406    }
1407
1408    // --- Token conversion ---
1409
1410    /// Convert a tokenizer token into a PsObject.
1411    pub fn token_to_object(&mut self, token: crate::tokenizer::Token) -> Result<PsObject, PsError> {
1412        use crate::tokenizer::Token;
1413        match token {
1414            Token::Int(v) => Ok(PsObject::int(v)),
1415            Token::Real(v) => Ok(PsObject::real(v)),
1416            Token::String(bytes) => {
1417                let save_level = self.save_stack.current_level();
1418                let global = self.vm_alloc_mode;
1419                let created = self.save_stack.last_save_id();
1420                let entity = self
1421                    .strings
1422                    .allocate_with(bytes.len(), save_level, global, created);
1423                self.strings
1424                    .get_mut(entity, 0, bytes.len() as u32)
1425                    .copy_from_slice(&bytes);
1426                let mut obj = PsObject::string(entity, bytes.len() as u32);
1427                if global {
1428                    obj.flags = ObjFlags::new(ObjFlags::ACCESS_UNLIMITED, false, true, true);
1429                }
1430                Ok(obj)
1431            }
1432            Token::Name(bytes, is_exec) => {
1433                let id = self.names.intern(&bytes);
1434                if is_exec {
1435                    Ok(PsObject::name_exec(id))
1436                } else {
1437                    Ok(PsObject::name_lit(id))
1438                }
1439            }
1440            Token::LiteralName(bytes) => {
1441                let id = self.names.intern(&bytes);
1442                Ok(PsObject::name_lit(id))
1443            }
1444            Token::ImmediateName(bytes) => {
1445                let id = self.names.intern(&bytes);
1446                let key = DictKey::Name(id);
1447                self.dict_load(&key).ok_or(PsError::Undefined)
1448            }
1449            Token::ArrayBegin => {
1450                let id = self.names.intern(b"[");
1451                Ok(PsObject::name_exec(id))
1452            }
1453            Token::ArrayEnd => {
1454                let id = self.names.intern(b"]");
1455                Ok(PsObject::name_exec(id))
1456            }
1457            Token::DictBegin => {
1458                let id = self.names.intern(b"<<");
1459                Ok(PsObject::name_exec(id))
1460            }
1461            Token::DictEnd => {
1462                let id = self.names.intern(b">>");
1463                Ok(PsObject::name_exec(id))
1464            }
1465            Token::ProcBegin | Token::ProcEnd | Token::Eof | Token::BinaryTokenByte(_) => {
1466                Err(PsError::SyntaxError)
1467            }
1468        }
1469    }
1470
1471    /// Reset local VM stores (for job boundary cleanup).
1472    /// Full implementation deferred until job server loop is built.
1473    pub fn reset_local_vm(&mut self) {
1474        self.strings.reset_local();
1475        self.arrays.reset_local();
1476        self.dicts.reset_local();
1477    }
1478}
1479
1480impl Default for Context {
1481    fn default() -> Self {
1482        Self::new()
1483    }
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488    use super::*;
1489
1490    #[test]
1491    fn test_context_creation() {
1492        let ctx = Context::new();
1493        assert!(ctx.o_stack.is_empty());
1494        assert!(ctx.e_stack.is_empty());
1495        assert_eq!(ctx.d_stack.len(), 3); // systemdict, globaldict, userdict
1496    }
1497
1498    #[test]
1499    fn test_dict_def_and_load() {
1500        let mut ctx = Context::new();
1501        let key = DictKey::Name(ctx.names.intern(b"foo"));
1502        ctx.dict_def(key.clone(), PsObject::int(42)).unwrap();
1503
1504        let val = ctx.dict_load(&key).unwrap();
1505        assert_eq!(val.as_i32(), Some(42));
1506    }
1507
1508    #[test]
1509    fn test_dict_where() {
1510        let mut ctx = Context::new();
1511        let key = DictKey::Name(ctx.names.intern(b"true"));
1512        let result = ctx.dict_where(&key);
1513        assert!(result.is_some());
1514        let (dict_id, val) = result.unwrap();
1515        assert_eq!(dict_id, ctx.systemdict);
1516        assert!(matches!(val.value, PsValue::Bool(true)));
1517    }
1518
1519    #[test]
1520    fn test_dict_store_existing() {
1521        let mut ctx = Context::new();
1522        let key = DictKey::Name(ctx.names.intern(b"myvar"));
1523
1524        // Define in userdict
1525        ctx.dict_def(key.clone(), PsObject::int(1)).unwrap();
1526
1527        // Store should update the existing entry in userdict
1528        ctx.dict_store(key.clone(), PsObject::int(2)).unwrap();
1529
1530        let val = ctx.dict_load(&key).unwrap();
1531        assert_eq!(val.as_i32(), Some(2));
1532    }
1533
1534    #[test]
1535    fn test_save_restore_basic() {
1536        let mut ctx = Context::new();
1537        let key = DictKey::Name(ctx.names.intern(b"testvar"));
1538
1539        // Define before save
1540        ctx.dict_def(key.clone(), PsObject::int(1)).unwrap();
1541
1542        // Save
1543        let save_obj = ctx.vm_save();
1544        let save_id = match save_obj.value {
1545            PsValue::Save(SaveLevel(id)) => id,
1546            _ => panic!("Expected Save"),
1547        };
1548
1549        // Modify after save
1550        ctx.dict_def(key.clone(), PsObject::int(2)).unwrap();
1551        assert_eq!(ctx.dict_load(&key).unwrap().as_i32(), Some(2));
1552
1553        // Restore
1554        ctx.vm_restore(save_id).unwrap();
1555        assert_eq!(ctx.dict_load(&key).unwrap().as_i32(), Some(1));
1556    }
1557
1558    #[test]
1559    fn test_save_restore_string() {
1560        let mut ctx = Context::new();
1561
1562        let entity = ctx.strings.allocate_from_at_level_zero(b"hello");
1563
1564        // Save
1565        let save_obj = ctx.vm_save();
1566        let save_id = match save_obj.value {
1567            PsValue::Save(SaveLevel(id)) => id,
1568            _ => panic!("Expected Save"),
1569        };
1570
1571        // Modify after save
1572        ctx.cow_check_string(entity);
1573        ctx.strings.put_byte(entity, 0, b'H');
1574        assert_eq!(ctx.strings.get(entity, 0, 5), b"Hello");
1575
1576        // Restore
1577        ctx.vm_restore(save_id).unwrap();
1578        assert_eq!(ctx.strings.get(entity, 0, 5), b"hello");
1579    }
1580
1581    #[test]
1582    fn test_save_restore_array() {
1583        let mut ctx = Context::new();
1584
1585        let items = [PsObject::int(1), PsObject::int(2), PsObject::int(3)];
1586        let entity = ctx.arrays.allocate_from_at_level_zero(&items);
1587
1588        let save_obj = ctx.vm_save();
1589        let save_id = match save_obj.value {
1590            PsValue::Save(SaveLevel(id)) => id,
1591            _ => panic!("Expected Save"),
1592        };
1593
1594        ctx.cow_check_array(entity);
1595        ctx.arrays.set_element(entity, 1, PsObject::int(99));
1596        assert_eq!(ctx.arrays.get_element(entity, 1).as_i32(), Some(99));
1597
1598        ctx.vm_restore(save_id).unwrap();
1599        assert_eq!(ctx.arrays.get_element(entity, 1).as_i32(), Some(2));
1600    }
1601
1602    #[test]
1603    fn test_invalid_restore() {
1604        let mut ctx = Context::new();
1605        // Restore without save
1606        assert_eq!(ctx.vm_restore(999), Err(PsError::InvalidRestore));
1607    }
1608
1609    /// The debug-build guard has to actually fire, or it is decoration.
1610    ///
1611    /// Planted in `userdict` rather than in one of the entity-keyed caches:
1612    /// `userdict` predates the save, so it survives the restore, and writing
1613    /// to it without copy-on-write leaves the reference behind with no backup
1614    /// to revert. That is the shape of the bug class fixed in 04ddc25, and it
1615    /// is a path no amount of cache purging can cover.
1616    #[test]
1617    #[cfg(debug_assertions)]
1618    #[should_panic(expected = "dangling reference")]
1619    fn vm_restore_rejects_a_dangling_reference() {
1620        let mut ctx = Context::new();
1621        let save_obj = ctx.vm_save();
1622        let save_id = match save_obj.value {
1623            PsValue::Save(SaveLevel(id)) => id,
1624            _ => panic!("Expected Save"),
1625        };
1626        let retired = EntityId(ctx.dicts.local.entities.len() as u32);
1627        let key = DictKey::Name(ctx.names.intern(b"stale"));
1628        ctx.dicts.put(ctx.userdict, key, PsObject::dict(retired));
1629        let _ = ctx.vm_restore(save_id);
1630    }
1631
1632    /// `gstate` objects index `gstate_store`, which `restore` now rewinds to
1633    /// its length at save time.
1634    #[test]
1635    fn restore_rewinds_gstate_store() {
1636        let mut ctx = Context::new();
1637        ctx.gstate_store.push(ctx.gstate.clone());
1638        let save_obj = ctx.vm_save();
1639        let save_id = match save_obj.value {
1640            PsValue::Save(SaveLevel(id)) => id,
1641            _ => panic!("Expected Save"),
1642        };
1643        ctx.gstate_store.push(ctx.gstate.clone());
1644        assert_eq!(ctx.gstate_store.len(), 2);
1645        ctx.vm_restore(save_id).unwrap();
1646        assert_eq!(ctx.gstate_store.len(), 1);
1647    }
1648}