collective

Browse / github.com/storytold/craft-libs / patch

PDF: expose cooperative interpretation checkpoints

PDF: expose cooperative interpretation checkpoints A live document action can keep interpreting PDF content after its caller moves on. An atomic flag cannot meter work on a single-threaded WebAssembly host. This adds an optional synchronous `CancellationCheck` to the interpreter settings; clones share a sticky stop latch so one cancellation signal or work budget can span pages and nested forms. The interpreter checks before parsing operators, between content windows and annotations, and inside text arrays and long glyph strings. The raster facade checks before allocation and around drawing, returning `Cancelled` instead of successful partial

Status
open
Score
0.00
Adopters
0 other operators
Author
CypherNaught-0x (imported from pull request #240, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+293 −18 in 7 files
Review
pending
Shared
2026-10-28
Id
2c92cb9068d1ef9071bb41f8

How it went

No other operator has reported using it yet.

Diff

raw · crates/pdf/README.md crates/pdf/src/raster.rs crates/pdf/tests/checkpoints.rs vendor/README.craft-reader.md vendor/hayro-interpret/SOURCE.json vendor/hayro-interpret/src/interpret/mod.rs vendor/hayro-interpret/src/interpret/text.rs

diff --git a/crates/pdf/README.md b/crates/pdf/README.md--- a/crates/pdf/README.md+++ b/crates/pdf/README.md@@ -53,6 +53,15 @@ Native unwinding panics become errors; discard partial devices, buffers, parsers after backend failure. WASM aborts and process-wide allocation failure cannot be caught. Cancellation is polled between operators, so a decoder may finish before noticing it. +`InterpreterSettings::cancellation_check` optionally accepts a `CancellationCheck`, a synchronous+caller-owned checkpoint for cancellation or work budgets. Clones share a sticky cancellation latch,+so one run can keep its budget across pages without clocks or threads, including on WebAssembly.+Checks also occur within long text strings and arrays, between stream windows and annotations,+and around rasterization. A stopped raster returns `Cancelled`; callers must discard partial+Device or buffer output. The default remains flag-only behavior. A checkpoint cannot preempt an+individual codec or graphics operation already in progress. Callback panics become guarded native+errors at facade entry points; WebAssembly retains its existing abort limitation.+ ## Backend provenance and consumer pins  The shared reader vendors the current patched Hayro trees from PdfCraft revision@@ -72,8 +81,9 @@ revision until they explicitly adopt a new one.  Consumers pin `craft-pdf` and their required root `[patch.crates-io]` entries to the same full `https://github.com/storytold/craft-libs` revision. Cargo ignores dependency-local patches.-Reader consumers patch `hayro-syntax`, `hayro-interpret` and `hayro-jbig2`; raster consumers-also patch `hayro`. Writer consumers patch `krilla`. A plain `write` consumer does not need+Reader consumers patch `hayro-syntax`, `hayro-interpret`, `hayro-jbig2`, `hayro-cmap` and+`hayro-postscript`; raster consumers also patch `hayro`. The CMap/PostScript pins retain the+patched parser's stack-safety guarantees. Writer consumers patch `krilla`. A plain `write` consumer does not need reader patches. Keep unrelated craft-ui/craft-print versions unchanged when adopting PDF. A pinned commit must be available remotely before a fresh public checkout can resolve it. @@ -88,7 +98,7 @@ stay outside this repository; dependency assets and licences are in the root att The Shared PDF workflow runs native and WebAssembly facade contracts, an independent `read-only-contract` consumer, and the transferred backend tests. The independent consumer keeps interpreter-bearing dev-dependencies out of the shipping `read` feature graph.-Run `bash scripts/test-pdf-backends.sh` from the repository root to test all four vendored+Run `bash scripts/test-pdf-backends.sh` from the repository root to test all six vendored backends, the syntax doctest and the syntax-only configuration. The script uses temporary source copies and the complete local patch set, preserving the vendored lockfiles. Run `cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked` for thediff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs--- a/crates/pdf/src/raster.rs+++ b/crates/pdf/src/raster.rs@@ -71,6 +71,10 @@ pub enum Error {     BackendPanic, } +fn cancellation_requested(settings: &InterpreterSettings) -> Result<bool, Error> {+    catch_unwind(AssertUnwindSafe(|| settings.is_cancelled())).map_err(|_| Error::BackendPanic)+}+ /// Validate geometry before converting sizes or allocating. A zero-size output is rejected. pub fn dimensions(page: &Page<'_>, options: &Options) -> Result<[u32; 2], Error> {     let (w, h) = page.render_dimensions();@@ -110,8 +114,7 @@ pub fn render_into<'a>(     if u64::try_from(buffer.len()).ok() != Some(expected) {         return Err(Error::BufferLength);     }-    let cancelled = || settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed));-    if cancelled() {+    if cancellation_requested(settings)? {         return Err(Error::Cancelled);     }     let warnings = Arc::new(AtomicU8::new(0));@@ -145,7 +148,7 @@ pub fn render_into<'a>(         ),     };     catch_unwind(AssertUnwindSafe(|| hayro::render_into(page, cache, &settings, &rs, buffer))).map_err(|_| Error::BackendPanic)?;-    if cancelled() {+    if cancellation_requested(&settings)? {         return Err(Error::Cancelled);     }     let bits = warnings.load(Ordering::Relaxed);@@ -171,6 +174,9 @@ impl Raster {  pub fn render<'a>(page: &'a Page<'a>, cache: &RenderCache<'a>, settings: &InterpreterSettings, options: &Options) -> Result<Raster, Error> {     let [width, height] = dimensions(page, options)?;+    if cancellation_requested(settings)? {+        return Err(Error::Cancelled);+    }     let count = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| Error::OutputLimit)?;     let mut words = Vec::new();     words.try_reserve_exact(count).map_err(|_| Error::Allocation)?;diff --git a/crates/pdf/tests/checkpoints.rs b/crates/pdf/tests/checkpoints.rsnew file mode 100644--- /dev/null+++ b/crates/pdf/tests/checkpoints.rs@@ -0,0 +1,192 @@+#![cfg(feature = "interpret")]+#![allow(clippy::unwrap_used, clippy::panic)]++use craft_pdf::interpret::{+    BlendMode, CancellationCheck, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode,+    SoftMask, TransformExt, font::Glyph,+};+use kurbo::{Affine, BezPath, Rect};+use std::sync::{+    Arc,+    atomic::{AtomicBool, AtomicUsize, Ordering},+};++#[derive(Default)]+struct Counter {+    paths: usize,+    glyphs: usize,+    annotation_depth: i32,+    stop_at_annotations: Option<Arc<AtomicBool>>,+}++impl Device<'_> for Counter {+    fn begin_annotations(&mut self) {+        self.annotation_depth += 1;+        if let Some(stop) = &self.stop_at_annotations {+            stop.store(true, Ordering::Relaxed);+        }+    }+    fn end_annotations(&mut self) {+        self.annotation_depth -= 1;+    }+    fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}+    fn set_blend_mode(&mut self, _: BlendMode) {}+    fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {+        self.paths += 1;+    }+    fn push_clip_path(&mut self, _: &ClipPath) {}+    fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}+    fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {+        self.glyphs += 1;+    }+    fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}+    fn pop_clip_path(&mut self) {}+    fn pop_transparency_group(&mut self) {}+}++fn fixture(content: &str, form: &str) -> craft_pdf::syntax::Pdf {+    fixture_with_annotations(content, form, "")+}++fn fixture_with_annotations(content: &str, form: &str, annotations: &str) -> craft_pdf::syntax::Pdf {+    let bytes = format!(+        "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\+         2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\+         3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Resources << /Font << /F1 5 0 R >> /XObject << /F0 6 0 R >> >> /Contents 4 0 R {annotations} >> endobj\n\+         4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\+         5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\+         6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Resources << >> /Length {} >> stream\n{form}\nendstream endobj\n\+         trailer << /Root 1 0 R >>\n%%EOF\n",+        content.len(),+        form.len(),+    );+    craft_pdf::open_slice(bytes.as_bytes(), None).unwrap()+}++fn execute(pdf: &craft_pdf::syntax::Pdf, settings: InterpreterSettings) -> Counter {+    let page = pdf.pages().first().unwrap();+    let cache = InterpreterCache::new();+    let mut context = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 20.0), &cache, page.xref(), settings);+    let mut counter = Counter::default();+    craft_pdf::interpret_page(page, &mut context, &mut counter).unwrap();+    counter+}++fn budget(units: usize) -> CancellationCheck {+    let left = AtomicUsize::new(units);+    CancellationCheck::new(move || {+        let mut current = left.load(Ordering::Relaxed);+        loop {+            let Some(next) = current.checked_sub(1) else { return true };+            match left.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {+                Ok(_) => return false,+                Err(actual) => current = actual,+            }+        }+    })+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn cancellation_is_sticky_and_shared_by_clones() {+    let calls = Arc::new(AtomicUsize::new(0));+    let observed = calls.clone();+    let check = CancellationCheck::new(move || observed.fetch_add(1, Ordering::Relaxed) == 1);+    let clone = check.clone();+    assert!(!check.is_cancelled());+    assert!(clone.is_cancelled());+    assert!(check.is_cancelled());+    assert!(clone.is_cancelled());+    assert_eq!(calls.load(Ordering::Relaxed), 2, "the callback would return false again without the shared latch");+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn absent_checkpoints_keep_complete_paths_and_one_long_text_string() {+    let content = format!("{}BT /F1 10 Tf 0 0 Td ({}) Tj ET", "0 0 1 1 re f\n".repeat(80), "A".repeat(4096));+    let counter = execute(&fixture(&content, ""), InterpreterSettings::default());+    assert_eq!(counter.paths, 80);+    assert_eq!(counter.glyphs, 4096);+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn a_work_budget_stops_inside_nested_form_operators() {+    let check = budget(64);+    let settings = InterpreterSettings { cancellation_check: Some(check.clone()), ..Default::default() };+    let counter = execute(&fixture("/F0 Do", &"0 0 1 1 re f\n".repeat(4096)), settings);+    assert!(counter.paths > 0 && counter.paths < 4096, "{} paths", counter.paths);+    assert!(check.is_cancelled());+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn a_work_budget_stops_inside_one_long_text_operator() {+    let content = format!("BT /F1 10 Tf 0 0 Td ({}) Tj ET", "A".repeat(4096));+    let check = budget(64);+    let settings = InterpreterSettings { cancellation_check: Some(check.clone()), ..Default::default() };+    let counter = execute(&fixture(&content, ""), settings);+    assert!(counter.glyphs > 0 && counter.glyphs < 4096, "{} glyphs", counter.glyphs);+    assert!(check.is_cancelled());+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn a_numeric_text_array_is_checked_before_its_final_glyph() {+    let content = format!("BT /F1 10 Tf 0 0 Td [{}(A)] TJ ET", "1 ".repeat(4096));+    let check = budget(64);+    let settings = InterpreterSettings { cancellation_check: Some(check.clone()), ..Default::default() };+    let counter = execute(&fixture(&content, ""), settings);+    assert_eq!(counter.glyphs, 0);+    assert!(check.is_cancelled());+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn a_later_context_cannot_reset_the_shared_budget() {+    let pdf = fixture(&"0 0 1 1 re f\n".repeat(4096), "");+    let settings = InterpreterSettings { cancellation_check: Some(budget(64)), ..Default::default() };+    assert!(execute(&pdf, settings.clone()).paths > 0);+    assert_eq!(execute(&pdf, settings).paths, 0);+}++#[cfg(feature = "raster")]+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn raster_never_reports_partial_budget_output_as_success() {+    use craft_pdf::raster::{self, Error, Options, RenderCache};+    let pdf = fixture(&"0 0 1 1 re f\n".repeat(4096), "");+    let settings = InterpreterSettings { cancellation_check: Some(budget(64)), ..Default::default() };+    let page = pdf.pages().first().unwrap();+    assert!(matches!(raster::render(page, &RenderCache::new(), &settings, &Options::default()), Err(Error::Cancelled)));+}++#[cfg(all(feature = "raster", not(target_arch = "wasm32")))]+#[test]+fn raster_checkpoint_panics_are_guarded_before_drawing_or_allocation() {+    use craft_pdf::raster::{self, Error, Options, RenderCache};+    let pdf = fixture("0 0 1 1 re f", "");+    let settings =+        InterpreterSettings { cancellation_check: Some(CancellationCheck::new(|| panic!("synthetic checkpoint failure"))), ..Default::default() };+    let page = pdf.pages().first().unwrap();+    assert!(matches!(raster::render(page, &RenderCache::new(), &settings, &Options::default()), Err(Error::BackendPanic)));+    let mut buffer = vec![0; 40 * 20 * 4];+    assert_eq!(raster::render_into(page, &RenderCache::new(), &settings, &Options::default(), &mut buffer), Err(Error::BackendPanic));+}++#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn cancellation_balances_the_annotation_device_scope() {+    let pdf = fixture_with_annotations("", "", "/Annots [<< /Subtype /Highlight /Rect [0 0 10 10] >>]");+    let page = pdf.pages().first().unwrap();+    let stop = Arc::new(AtomicBool::new(false));+    let observed = stop.clone();+    let settings =+        InterpreterSettings { cancellation_check: Some(CancellationCheck::new(move || observed.load(Ordering::Relaxed))), ..Default::default() };+    let cache = InterpreterCache::new();+    let mut context = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 20.0), &cache, page.xref(), settings);+    let mut counter = Counter { stop_at_annotations: Some(stop.clone()), ..Default::default() };+    craft_pdf::interpret_page(page, &mut context, &mut counter).unwrap();+    assert!(stop.load(Ordering::Relaxed));+    assert_eq!(counter.annotation_depth, 0, "cancellation must close the device scope opened by the updated backend");+}diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md--- a/vendor/README.craft-reader.md+++ b/vendor/README.craft-reader.md@@ -327,6 +327,12 @@ fonts without Encoding) and #1499 (byte-aligned packed TIFF predictor rows). Synthetic tests cover 1/2/4/8-bit RGB rows and rendering, plus embedded TrueType text with absent or partial ToUnicode maps and explicit encoding precedence. +A caller-owned `CancellationCheck` supplements the existing atomic cancellation flag. Its shared+sticky latch is polled between operators, text-array items, glyphs, content windows and annotations,+allowing an application to bound repeated interpretation work on single-threaded WebAssembly.+No application clock, fuel amount or scheduling policy enters the backend. Facade contracts cover+default completion, nested forms, long text and arrays, shared budgets and cancelled raster output.+ ## Craft patches for the flattener  Local changes for craft-prepress's PDF-in adapter (`craft_prepress::flatten::pdf`), which readsdiff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json--- a/vendor/hayro-interpret/SOURCE.json+++ b/vendor/hayro-interpret/SOURCE.json@@ -362,7 +362,7 @@       "path": "src/interpret/mod.rs",       "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2",       "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f",-      "sha256": "3f8958e8d234f00b24478acb8081aa1aaffdca6b8a12b24c4abc6f534ce09a05"+      "sha256": "fd718a7647bc19a36a0519dccb0615fed142f32e15d47775b3d88d668ffe02ce"     },     {       "path": "src/interpret/path.rs",@@ -380,7 +380,7 @@       "path": "src/interpret/text.rs",       "source_sha256": "35eafa80147aea635bdef127c33ace20e1695e11e87c7bfe1e035c886806ff30",       "source_git_blob": "ddec0400ea7a639c14769ac9ee7e137d000b3efd",-      "sha256": "b34117d28ca7987833626f2e938abe4dd5d02ea869db7b0bdc1b8a264d775f9c"+      "sha256": "3635d172ba30406551ed214d12f1c97b1169e2c485fdb26d699a7e85da41c321"     },     {       "path": "src/lib.rs",diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs--- a/vendor/hayro-interpret/src/interpret/mod.rs+++ b/vendor/hayro-interpret/src/interpret/mod.rs@@ -45,6 +45,43 @@ pub type CMapResolverFn = /// A callback function for resolving warnings during interpretation. pub type WarningSinkFn = Arc<dyn Fn(InterpreterWarning) + Send + Sync>; +/// A caller-owned, synchronous cancellation checkpoint, shared by clones.+///+/// The callback can inspect a cancellation signal or spend a caller's work budget, without+/// requiring a clock or another thread. It must return quickly. Once it requests cancellation,+/// the result is latched so a later callback response cannot make partial output look complete.+/// Checkpoints run between operators, content windows, text-array items and glyphs. They cannot+/// interrupt an individual codec or graphics operation that has already started.+#[derive(Clone)]+pub struct CancellationCheck(Arc<CancellationState>);++struct CancellationState {+    check: Box<dyn Fn() -> bool + Send + Sync>,+    stopped: AtomicBool,+}++impl CancellationCheck {+    /// Creates a checkpoint whose callback returns true when the output must be discarded.+    pub fn new(check: impl Fn() -> bool + Send + Sync + 'static) -> Self {+        Self(Arc::new(CancellationState {+            check: Box::new(check),+            stopped: AtomicBool::new(false),+        }))+    }++    /// Polls the callback, or returns the latched cancellation without calling it again.+    #[inline]+    pub fn is_cancelled(&self) -> bool {+        if self.0.stopped.load(Ordering::Acquire) {+            return true;+        }+        if (self.0.check)() {+            self.0.stopped.store(true, Ordering::Release);+        }+        self.0.stopped.load(Ordering::Acquire)+    }+}+ #[derive(Clone)] /// Settings that should be applied during the interpretation process. pub struct InterpreterSettings {@@ -115,6 +152,9 @@ pub struct InterpreterSettings {     /// operator. What was drawn is then incomplete, so only set it when the output will be     /// thrown away (a viewer's render whose result nobody can receive any more).     pub cancelled: Option<Arc<AtomicBool>>,+    /// A synchronous cooperative checkpoint, combined with `cancelled`. Clones share its+    /// cancellation latch. The default is absent, preserving the existing flag-only behavior.+    pub cancellation_check: Option<CancellationCheck>,     /// PdfCraft patch: a low-detail render for previews (page backdrops, thumbnails). Images     /// that would cover only a few device pixels are skipped: redrawing them at full detail     /// costs a decode and a resample each for pixels nobody can see. Never set it for a final@@ -131,6 +171,15 @@ pub struct InterpreterSettings {     pub ink_sink: Option<crate::color::InkSinkFn>, } +impl InterpreterSettings {+    /// Polls the caller's flag and optional synchronous checkpoint.+    #[inline]+    pub fn is_cancelled(&self) -> bool {+        self.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))+            || self.cancellation_check.as_ref().is_some_and(CancellationCheck::is_cancelled)+    }+}+ impl Default for InterpreterSettings {     fn default() -> Self {         Self {@@ -150,6 +199,7 @@ impl Default for InterpreterSettings {             hide_comments: false,             ocg_overrides: Arc::new(Vec::new()),             cancelled: None,+            cancellation_check: None,             preview: false,             plate: None,             ink_sink: None,@@ -176,6 +226,9 @@ pub fn interpret_page<'a>(     context: &mut Context<'a>,     device: &mut impl Device<'a>, ) {+    if context.settings.is_cancelled() {+        return;+    }     let resources = page.resources();     let mut content = page.content_reader();     // PdfCraft patch: the page's own contents count against its content budget too, where they@@ -190,6 +243,9 @@ pub fn interpret_page<'a>(         // the document's own (the reading-colours setting recolours the document, not comments).         device.begin_annotations();         for annot in annot_arr.iter::<Dict<'_>>() {+            if context.settings.is_cancelled() {+                break;+            }             let flags = annot.get::<u32>(F).unwrap_or(0);              // Annotation should be hidden (Hidden = bit 2, NoView = bit 6).@@ -380,18 +436,18 @@ fn interpret_window<'a>(     context: &mut Context<'a>,     device: &mut impl Device<'a>, ) {-    // PdfCraft patch (#3018): the resources' DefaultGray, DefaultRGB and DefaultCMYK replace the-    // device spaces that color operators and images select (ISO 32000-2 §8.6.5.6).+    // PdfCraft patch (#3018): keep the resource defaults alongside the cooperative+    // checkpoint, which must run before parsing the next operator.     let defaults = DefaultColorSpaces::new(         resources,         &context.interpreter_cache.object_cache,         context.settings.plate.is_some(),     );-    while let Some(op) = ops.next() {-        // PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`).-        if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) {+    loop {+        if context.settings.is_cancelled() {             break;         }+        let Some(op) = ops.next() else { break };         match op {             TypedInstruction::SaveState(_) => context.save_state(),             TypedInstruction::StrokeColorDeviceRgb(s) => {@@ -971,11 +1027,7 @@ fn interpret_content<'a>(         };         window.drain(..committed); -        let cancelled = context-            .settings-            .cancelled-            .as_ref()-            .is_some_and(|c| c.load(Ordering::Relaxed));+        let cancelled = context.settings.is_cancelled();         if !needs_more || cancelled || exhausted {             break;         }diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs--- a/vendor/hayro-interpret/src/interpret/text.rs+++ b/vendor/hayro-interpret/src/interpret/text.rs@@ -25,6 +25,9 @@ pub(crate) fn show_text_array<'a>( ) {     show_text_run(ctx, |ctx, pass| {         for item in text.iter::<object::Object<'_>>() {+            if ctx.settings.is_cancelled() {+                break;+            }             match item {                 object::Object::Number(number) => ctx.get_mut().text_state.apply_adjustment(number.as_f32()),                 object::Object::String(text) => show_string_pass(ctx, device, resources, &text, pass),@@ -51,6 +54,9 @@ fn show_text_run<'a>(ctx: &mut Context<'a>, mut draw: impl FnMut(&mut Context<'a     ) {         let start = ctx.get().text_state.text_matrix;         draw(ctx, TextPass::Fill);+        if ctx.settings.is_cancelled() {+            return;+        }         let end = ctx.get().text_state.text_matrix;         ctx.get_mut().text_state.text_matrix = start;         draw(ctx, TextPass::Stroke);@@ -84,6 +90,9 @@ fn show_string_pass<'a>(     let mut cur_idx = 0;      while cur_idx < bytes.len() {+        if ctx.settings.is_cancelled() {+            break;+        }         let (code, adv) = font.read_code(bytes, cur_idx);         cur_idx += adv;