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 the
diff --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.rs
new 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 reads
diff --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;
 
