diff --git a/src/backend/workers/python/build_package.py b/src/backend/workers/python/build_package.py index 7a8314b6..ad073931 100644 --- a/src/backend/workers/python/build_package.py +++ b/src/backend/workers/python/build_package.py @@ -4,6 +4,7 @@ import os import subprocess import sys +import sysconfig def tarfile_filter(tar_info): name = tar_info.name @@ -27,6 +28,10 @@ def create_package(package_name, dependencies, extra_deps): except Exception as e: # Always seems to result in a harmless permission denied error pass + # Bundle CPython's turtle.py (removed from Pyodide's stdlib, required by svg-turtle). + # Locate via sysconfig rather than `import turtle`, which would pull in tkinter. + turtle_src = os.path.join(sysconfig.get_path("stdlib"), "turtle.py") + shutil.copy(turtle_src, os.path.join(package_name, "turtle.py")) tar_name = f"{package_name}.tar.gz.load_by_url" if os.path.exists(tar_name): os.remove(tar_name) @@ -45,4 +50,4 @@ def check_tar(tarname, out_dir="."): if __name__ == "__main__": - create_package("python_package", "python-runner friendly_traceback pylint>=4,<5 tomli typing-extensions json-tracer>=0.7.0", extra_deps="papyros") + create_package("python_package", "python-runner friendly_traceback pylint>=4,<5 tomli typing-extensions json-tracer>=0.7.0 svg-turtle", extra_deps="papyros") diff --git a/src/backend/workers/python/papyros/linting.py b/src/backend/workers/python/papyros/linting.py index fafc6866..225e8765 100644 --- a/src/backend/workers/python/papyros/linting.py +++ b/src/backend/workers/python/papyros/linting.py @@ -9,7 +9,7 @@ PYLINT_RC_FILE = os.path.abspath("/tmp/papyros/pylint_config.rc") -PYLINT_PLUGINS = "pylint_ast_checker" +PYLINT_PLUGINS = "pylint_ast_checker,pylint_turtle_brain" def lint(code): # Use temporary file to prevent Astroid cache from running into issues diff --git a/src/backend/workers/python/papyros/papyros.py b/src/backend/workers/python/papyros/papyros.py index deafa3b0..d15df0be 100644 --- a/src/backend/workers/python/papyros/papyros.py +++ b/src/backend/workers/python/papyros/papyros.py @@ -17,12 +17,14 @@ from pyodide_worker_runner import install_imports from pyodide.ffi import JsException, create_proxy from .util import to_py +from .turtle_hook import TurtleImportHook from pyodide.http import pyfetch from types import ModuleType SYS_RECURSION_LIMIT = 500 MODULE_NAME = "sandbox" + class Papyros(python_runner.PyodideRunner): def __init__( self, @@ -47,6 +49,8 @@ def __init__( self._tracking_files = False self._original_open = builtins.open self._last_emitted_snapshot = None + self._last_emitted_turtle_svg = None + self._turtle_hook = TurtleImportHook() self._install_open_tracking() self.limit = limit self.override_globals() @@ -74,6 +78,7 @@ def cb(typ, dat, contentType=None, **kwargs): elif event_type == "input": return cb("input", data["prompt"]) elif event_type == "sleep": + self._emit_turtle_snapshot() return cb("sleep", data["seconds"]*1000, contentType="application/number") else: return cb(event_type, data.get("data", ""), contentType=data.get("contentType")) @@ -86,6 +91,26 @@ def override_globals(self): # Otherwise `import matplotlib` fails while assuming a browser backend os.environ["MPLBACKEND"] = "AGG" self.override_matplotlib() + self.override_turtle() + + def _emit_turtle_snapshot(self): + if not self._turtle_hook.render: + return + from svg_turtle import SvgTurtle + svg_string = SvgTurtle._pen.to_svg() + if svg_string and svg_string != self._last_emitted_turtle_svg: + self._last_emitted_turtle_svg = svg_string + img = base64.b64encode(svg_string.encode("utf-8")).decode("utf-8") + self.callback("turtle", data=img, contentType="image/svg+xml;base64") + + def override_turtle(self): + hook = self._turtle_hook + hook.papyros = self + hook.render = None + # Remove turtle from sys.modules so the hook intercepts the next import + sys.modules.pop('turtle', None) + if hook not in sys.meta_path: + sys.meta_path.insert(0, hook) def override_matplotlib(self): try: @@ -196,6 +221,7 @@ def _execute_context(self): self._tracked_files.clear() self._tracking_files = True self._last_emitted_snapshot = None + self._last_emitted_turtle_svg = None with ( redirect_stdout(python_runner.output.SysStream("output", self.output_buffer)), redirect_stderr(python_runner.output.SysStream("error", self.output_buffer)), @@ -206,6 +232,7 @@ def _execute_context(self): self.output("traceback", **self.serialize_traceback(e)) self._flush_open_files() self._emit_created_files() + self._emit_turtle_snapshot() finally: self._tracking_files = False self.post_run() @@ -229,9 +256,11 @@ async def run_async(self, source_code, mode="exec", top_level_await=True): self.callback("start", data="RunCode", contentType="text/plain") if mode == "debug": from tracer import JSONTracer + def frame_callback(frame): self._flush_open_files() self._emit_created_files() + self._emit_turtle_snapshot() self.callback("frame", data=frame, contentType="application/json") result = JSONTracer(frame_callback=frame_callback, module_name=MODULE_NAME).runscript(source_code) @@ -241,6 +270,7 @@ def frame_callback(frame): result = await result self._flush_open_files() self._emit_created_files() + self._emit_turtle_snapshot() self.callback("end", data="CodeFinished", contentType="text/plain") return result except ModuleNotFoundError as mnf: @@ -257,6 +287,7 @@ def frame_callback(frame): # with a js_error containing the reason js_error = str(getattr(e, "js_error", "")) if isinstance(e, KeyboardInterrupt) or "KeyboardInterrupt" in js_error: + self._emit_turtle_snapshot() self.callback("interrupt", data="KeyboardInterrupt", contentType="text/plain") else: raise diff --git a/src/backend/workers/python/papyros/pylint_turtle_brain.py b/src/backend/workers/python/papyros/pylint_turtle_brain.py new file mode 100644 index 00000000..d0894c6d --- /dev/null +++ b/src/backend/workers/python/papyros/pylint_turtle_brain.py @@ -0,0 +1,59 @@ +# Astroid brain plugin for Python's turtle module. +# turtle.py creates module-level functions dynamically via exec() in +# _make_global_funcs(), so astroid can't see them statically. +# This plugin injects stubs for those functions so pylint can check +# valid vs invalid member access (e.g. turtle.forward is OK, +# turtle.test is not). + +import astroid +from astroid import MANAGER + +# These lists mirror CPython 3.13's turtle._tg_turtle_functions and +# turtle._tg_screen_functions — the functions generated at module level. +_TG_TURTLE_FUNCTIONS = [ + 'back', 'backward', 'begin_fill', 'begin_poly', 'bk', + 'circle', 'clear', 'clearstamp', 'clearstamps', 'clone', 'color', + 'degrees', 'distance', 'dot', 'down', 'end_fill', 'end_poly', 'fd', + 'fillcolor', 'filling', 'forward', 'get_poly', 'getpen', 'getscreen', + 'getturtle', 'goto', 'heading', 'hideturtle', 'home', 'ht', 'isdown', + 'isvisible', 'left', 'lt', 'onclick', 'ondrag', 'onrelease', 'pd', + 'pen', 'pencolor', 'pendown', 'pensize', 'penup', 'pos', 'position', + 'pu', 'radians', 'right', 'reset', 'resizemode', 'rt', 'seth', + 'setheading', 'setpos', 'setposition', 'settiltangle', + 'setundobuffersize', 'setx', 'sety', 'shape', 'shapesize', + 'shapetransform', 'shearfactor', 'showturtle', 'speed', 'st', 'stamp', + 'teleport', 'tilt', 'tiltangle', 'towards', 'turtlesize', 'undo', + 'undobuffercount', 'up', 'width', 'write', 'xcor', 'ycor', +] + +_TG_SCREEN_FUNCTIONS = [ + 'addshape', 'bgcolor', 'bgpic', 'bye', 'clearscreen', 'colormode', + 'delay', 'exitonclick', 'getcanvas', 'getshapes', 'listen', + 'mainloop', 'mode', 'numinput', 'onkey', 'onkeypress', 'onkeyrelease', + 'onscreenclick', 'ontimer', 'register_shape', 'resetscreen', + 'screensize', 'setup', 'setworldcoordinates', 'textinput', 'title', + 'tracer', 'turtles', 'update', 'window_height', 'window_width', 'done', +] + +_ALL_FUNCTIONS = _TG_TURTLE_FUNCTIONS + _TG_SCREEN_FUNCTIONS + + +def _turtle_transform(module): + """Add stub definitions for turtle's dynamically generated functions.""" + code = "\n".join(f"def {name}(*args, **kwargs): ..." for name in _ALL_FUNCTIONS) + fake = astroid.parse(code) + for node in fake.body: + module.body.append(node) + module.locals[node.name] = [node] + + +MANAGER.register_transform( + astroid.Module, + _turtle_transform, + lambda node: node.name == 'turtle', +) + + +def register(linter): + """Required by pylint plugin interface (no checkers to register).""" + pass diff --git a/src/backend/workers/python/papyros/turtle_hook.py b/src/backend/workers/python/papyros/turtle_hook.py new file mode 100644 index 00000000..fcb820b1 --- /dev/null +++ b/src/backend/workers/python/papyros/turtle_hook.py @@ -0,0 +1,69 @@ +import sys + + +class TurtleImportHook: + """Import hook that lazily sets up SVG-based turtle graphics. + + Installed in sys.meta_path. When user code does `import turtle`, + this hook intercepts it, imports svg-turtle, creates a shared + canvas/screen, and patches the turtle module to render SVG. + + If user code never imports turtle, no setup occurs. + """ + + def __init__(self): + self.papyros = None + self.render = None + self._loading = False + self._turtle_module = None + + def find_spec(self, name, path, target=None): + if name == 'turtle' and not self._loading: + import importlib.util + return importlib.util.spec_from_loader(name, self) + return None + + def create_module(self, spec): + return self._setup_turtle() + + def exec_module(self, module): + pass + + def _setup_turtle(self): + self._loading = True + try: + from svg_turtle import SvgTurtle + from svg_turtle.canvas import Canvas + + if self._turtle_module is None: + # svg_turtle stubs tkinter as a side effect of import; must precede `import turtle` + import turtle + self._turtle_module = sys.modules['turtle'] + + turtle_mod = self._turtle_module + sys.modules['turtle'] = turtle_mod + + canvas = Canvas(400, 400) + screen = SvgTurtle._Screen(canvas) + screen.cv.config(bg="") + + class PapyrosTurtle(SvgTurtle): + def __init__(self): + super().__init__(screen=screen) + + SvgTurtle._screen = screen + SvgTurtle._pen = PapyrosTurtle() + + def render(): + self.papyros._emit_turtle_snapshot() + + turtle_mod.Turtle = PapyrosTurtle + turtle_mod.done = render + turtle_mod.mainloop = render + turtle_mod.exitonclick = render + turtle_mod.bye = render + + self.render = render + return turtle_mod + finally: + self._loading = False diff --git a/src/communication/BackendEvent.ts b/src/communication/BackendEvent.ts index 929479fa..81ddbc62 100644 --- a/src/communication/BackendEvent.ts +++ b/src/communication/BackendEvent.ts @@ -14,6 +14,7 @@ export enum BackendEventType { FrameChange = "frame-change", Stop = "stop", Files = "files", + Turtle = "turtle", } /** diff --git a/src/frontend/components/Output.ts b/src/frontend/components/Output.ts index cdfafc33..4d4ce60f 100644 --- a/src/frontend/components/Output.ts +++ b/src/frontend/components/Output.ts @@ -1,7 +1,8 @@ import { customElement } from "lit/decorators.js"; import { css, CSSResult, html, TemplateResult } from "lit"; -import { FriendlyError, OutputEntry, OutputType } from "../state/InputOutput"; +import { FriendlyError, OutputEntry, OutputType, OUTPUT_TAB, TURTLE_TAB } from "../state/InputOutput"; import { PapyrosElement } from "./PapyrosElement"; +import { tabButtonStyles } from "./shared-styles"; import "@material/web/icon/icon"; @customElement("p-output") @@ -11,8 +12,32 @@ export class Output extends PapyrosElement { :host { width: 100%; height: 100%; + display: flex; + flex-direction: column; + } + + .tabs { + display: flex; + flex-direction: row; + gap: 0.25rem; + padding-top: 0.25rem; + flex-shrink: 0; + position: relative; + z-index: 1; + } + + .content { + flex: 1; overflow: auto; - display: block; + container-type: size; + padding: 0.75rem; + background-color: var(--md-sys-color-surface-container-highest); + } + + .content.turtle { + margin-top: -1px; + padding: 0; + background-color: transparent; } img { @@ -22,6 +47,18 @@ export class Output extends PapyrosElement { margin: 0.5rem 0; } + img.turtle, + .turtle-placeholder { + width: min(100cqw, 100cqh); + height: min(100cqw, 100cqh); + max-width: 100%; + max-height: 100%; + margin: 0; + box-sizing: border-box; + background-color: var(--md-sys-color-surface-container-highest); + border: 1px solid var(--md-sys-color-outline-variant); + } + pre { font-family: monospace; margin: 0; @@ -39,6 +76,8 @@ export class Output extends PapyrosElement { md-icon { vertical-align: bottom; } + + ${tabButtonStyles} `; } @@ -65,7 +104,7 @@ export class Output extends PapyrosElement { get downloadOverflowUrl(): string { const blob = new Blob( this.overflow.map((o) => { - if (o.type === OutputType.img) { + if (o.type === OutputType.img || o.type === OutputType.turtle) { return `[Image output of type ${o.contentType} omitted]\n`; } else if (o.type === OutputType.stdout) { return o.content as string; @@ -97,11 +136,24 @@ export class Output extends PapyrosElement { } get renderedOutputs(): TemplateResult[] { - return this.outputs.map((o) => { + let outputsToRender: OutputEntry[]; + if (this.papyros.io.activeOutputTab === TURTLE_TAB) { + // Latest snapshot within this.outputs (which is sliced by the debugger's current + // step via maxOutputLength) — so stepping the debugger shows the drawing build up. + const lastIdx = this.outputs.findLastIndex((o) => o.type === OutputType.turtle); + outputsToRender = lastIdx >= 0 ? [this.outputs[lastIdx]] : []; + } else { + outputsToRender = this.outputs.filter((o) => o.type !== OutputType.turtle); + } + return outputsToRender.map((o) => { if (o.type === OutputType.stdout) { return html`${o.content}`; } else if (o.type === OutputType.img) { - return html``; + const mimeType = o.contentType?.replace(/^img\//, "image/") ?? "image/png"; + return html``; + } else if (o.type === OutputType.turtle) { + const mimeType = o.contentType ?? "image/svg+xml;base64"; + return html``; } else if (o.type === OutputType.stderr) { if (typeof o.content === "string") { return html`${o.content}`; @@ -130,23 +182,59 @@ export class Output extends PapyrosElement { }); } - protected override render(): TemplateResult { - if (this.outputs.length === 0) { - return html`
${this.t("Papyros.output_placeholder")}
`; - } + private get showTurtleTab(): boolean { + return this.papyros.io.hasTurtleOutput || this.papyros.io.activeOutputTab === TURTLE_TAB; + } + private renderTabs(): TemplateResult { + const activeTab = this.papyros.io.activeOutputTab; + return html` +
+ + ${this.showTurtleTab + ? html` + + ` + : html``} +
+ `; + } + + protected override render(): TemplateResult { + const activeTab = this.papyros.io.activeOutputTab; + const rendered = this.renderedOutputs; + const showPlaceholder = activeTab === OUTPUT_TAB && rendered.length === 0; + const showTurtlePlaceholder = activeTab === TURTLE_TAB && rendered.length === 0; + const showOverflow = activeTab === OUTPUT_TAB && this.showOverflowWarning; return html` -
${this.renderedOutputs}
- ${this.showOverflowWarning - ? html` -

- ${this.t("Papyros.output_overflow")} - - ${this.t("Papyros.output_overflow_download")} - -

- ` - : html``} + ${this.renderTabs()} +
+ ${showPlaceholder + ? html`
${this.t("Papyros.output_placeholder")}
` + : showTurtlePlaceholder + ? html`
` + : html`
${rendered}
`} + ${showOverflow + ? html` +

+ ${this.t("Papyros.output_overflow")} + + ${this.t("Papyros.output_overflow_download")} + +

+ ` + : html``} +
`; } } diff --git a/src/frontend/components/app/examples/PythonExamples.ts b/src/frontend/components/app/examples/PythonExamples.ts index 161c1d8b..75a53d41 100644 --- a/src/frontend/components/app/examples/PythonExamples.ts +++ b/src/frontend/components/app/examples/PythonExamples.ts @@ -116,6 +116,14 @@ for _ in range(10): with open("names.txt", "r") as in_file: for line in in_file: print(line.rstrip()) +`, + Turtle: `import turtle + +colors = ['red', 'orange', 'yellow', 'green', 'blue', 'purple'] +for i in range(360): + turtle.pencolor(colors[i % 6]) + turtle.forward(i * 0.5) + turtle.left(59) `, Matplotlib: `import matplotlib.pyplot as plt import networkx as nx diff --git a/src/frontend/state/InputOutput.ts b/src/frontend/state/InputOutput.ts index 0774e8d2..f157fddd 100644 --- a/src/frontend/state/InputOutput.ts +++ b/src/frontend/state/InputOutput.ts @@ -40,6 +40,7 @@ export enum OutputType { stdout = "stdout", stderr = "stderr", img = "img", + turtle = "turtle", } export type OutputEntry = { @@ -61,6 +62,10 @@ export interface FileEntry { export const CODE_TAB = "code"; +export const OUTPUT_TAB = "output"; +export const TURTLE_TAB = "turtle"; +export type OutputTab = typeof OUTPUT_TAB | typeof TURTLE_TAB; + export function parseFileEntries(data: string, contentType?: string): FileEntry[] { const parsed = parseData(data, contentType) as Record; return Object.entries(parsed).map(([name, { content, binary }]) => ({ name, content, binary })); @@ -73,10 +78,16 @@ export class InputOutput extends State { @stateProperty output: OutputEntry[] = []; @stateProperty + hasTurtleOutput: boolean = false; + @stateProperty files: FileEntry[] = []; @stateProperty activeEditorTab: string = CODE_TAB; @stateProperty + activeOutputTab: OutputTab = OUTPUT_TAB; + @stateProperty + outputTabManuallySet: boolean = false; + @stateProperty prompt: string = ""; @stateProperty awaitingInput: boolean = false; @@ -119,6 +130,10 @@ export class InputOutput extends State { this.logOutput(data); } }); + BackendManager.subscribe(BackendEventType.Turtle, (e) => { + const data = parseData(e.data, e.contentType); + this.logTurtle(data, e.contentType); + }); BackendManager.subscribe(BackendEventType.Error, (e) => { const data = parseData(e.data, e.contentType); this.logError(data); @@ -134,6 +149,11 @@ export class InputOutput extends State { }); BackendManager.subscribe(BackendEventType.End, () => { this.awaitingInput = false; + // If the finished run produced no turtle output, drop the (stale) Turtle tab + // selection so the tab bar hides. A manual selection is preserved. + if (this.activeOutputTab === TURTLE_TAB && !this.hasTurtleOutput && !this.outputTabManuallySet) { + this.activeOutputTab = OUTPUT_TAB; + } }); BackendManager.subscribe(BackendEventType.Files, (e) => { this.files = parseFileEntries(e.data, e.contentType); @@ -159,6 +179,20 @@ export class InputOutput extends State { this.output = [...this.output, { type: OutputType.img, content: imageData, contentType }]; } + public logTurtle(imageData: string, contentType: string = "image/svg+xml;base64"): void { + const isFirstSnapshot = !this.hasTurtleOutput; + this.output = [...this.output, { type: OutputType.turtle, content: imageData, contentType }]; + this.hasTurtleOutput = true; + if (isFirstSnapshot && this.activeOutputTab === OUTPUT_TAB && !this.outputTabManuallySet) { + this.activeOutputTab = TURTLE_TAB; + } + } + + public selectOutputTab(tab: OutputTab): void { + this.activeOutputTab = tab; + this.outputTabManuallySet = true; + } + public logOutput(output: string): void { // lines have been merged to limit the number of events // we split them again here, to simplify overflow detection @@ -230,8 +264,12 @@ export class InputOutput extends State { public reset(): void { this.inputs = []; this.output = []; + this.hasTurtleOutput = false; this.prompt = ""; this.awaitingInput = false; this.activeEditorTab = CODE_TAB; + // activeOutputTab is intentionally preserved across reruns: resetting it would make + // the tab bar flicker (Turtle → Output → Turtle) as a turtle rerun progresses. It is + // pruned back to OUTPUT_TAB on End when no turtle output was produced. } } diff --git a/src/frontend/state/Translations.ts b/src/frontend/state/Translations.ts index f91c7558..2525c43d 100644 --- a/src/frontend/state/Translations.ts +++ b/src/frontend/state/Translations.ts @@ -79,6 +79,8 @@ export const ENGLISH_TRANSLATION = { files_download: "Download", files_binary: "Binary file", files_empty: "Empty file", + output_tab_output: "Output", + output_tab_turtle: "Turtle", }, CodeMirror: { // @codemirror/search @@ -175,6 +177,8 @@ export const DUTCH_TRANSLATION = { files_download: "Downloaden", files_binary: "Binair bestand", files_empty: "Leeg bestand", + output_tab_output: "Uitvoer", + output_tab_turtle: "Turtle", }, CodeMirror: { // @codemirror/view diff --git a/src/util/Util.ts b/src/util/Util.ts index 1fbbc79a..81cea4ea 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -45,9 +45,11 @@ export function parseData(data: string, contentType?: string): any { } break; } - case "img": { + case "img": + case "image": { switch (specificType) { - case "png;base64": { + case "png;base64": + case "svg+xml;base64": { return data; } } diff --git a/test/__tests__/state/Turtle.test.ts b/test/__tests__/state/Turtle.test.ts new file mode 100644 index 00000000..9c83e1a9 --- /dev/null +++ b/test/__tests__/state/Turtle.test.ts @@ -0,0 +1,71 @@ +import {describe, expect, it} from "vitest"; +import {Papyros} from "../../../src/frontend/state/Papyros"; +import {ProgrammingLanguage} from "../../../src/ProgrammingLanguage"; +import {RunState} from "../../../src/frontend/state/Runner"; +import {RunMode} from "../../../src/backend/Backend"; +import {OutputType} from "../../../src/frontend/state/InputOutput"; +import {waitForOutput, waitForPapyrosReady} from "../../helpers"; + +describe("Turtle", () => { + it("can load turtle and generate an SVG image", async () => { + const papyros = new Papyros(); + await papyros.launch(); + papyros.runner.programmingLanguage = ProgrammingLanguage.Python; + papyros.runner.code = `import turtle +t = turtle.Turtle() +t.forward(100) +t.right(90) +t.forward(100) +turtle.done()`; + await papyros.runner.start(); + await waitForPapyrosReady(papyros); + await waitForOutput(papyros); + expect(papyros.runner.state).toBe(RunState.Ready); + const turtleOutputs = papyros.io.output.filter(o => o.type === OutputType.turtle); + expect(turtleOutputs.length).toBeGreaterThan(0); + expect(turtleOutputs[0].contentType).toBe("image/svg+xml;base64"); + const decoded = atob(turtleOutputs[0].content as string); + expect(decoded).toContain(" { + const papyros = new Papyros(); + await papyros.launch(); + papyros.runner.programmingLanguage = ProgrammingLanguage.Python; + papyros.runner.code = `import turtle +import time +t = turtle.Turtle() +t.forward(50) +time.sleep(0.1) +t.right(90) +t.forward(50) +turtle.done()`; + await papyros.runner.start(); + await waitForPapyrosReady(papyros); + await waitForOutput(papyros, 2); + const turtleOutputs = papyros.io.output.filter(o => o.type === OutputType.turtle); + expect(turtleOutputs.length).toBeGreaterThanOrEqual(2); + // Each snapshot should be a valid base64-encoded SVG + for (const img of turtleOutputs) { + expect(img.contentType).toBe("image/svg+xml;base64"); + expect(atob(img.content as string)).toContain(" { + const papyros = new Papyros(); + await papyros.launch(); + papyros.runner.programmingLanguage = ProgrammingLanguage.Python; + papyros.runner.code = `import turtle +t = turtle.Turtle() +t.forward(100) +turtle.done()`; + await papyros.runner.start(RunMode.Debug); + await waitForPapyrosReady(papyros); + await waitForOutput(papyros); + const turtleOutputs = papyros.io.output.filter(o => o.type === OutputType.turtle); + expect(turtleOutputs.length).toBeGreaterThan(0); + expect(turtleOutputs[0].contentType).toBe("image/svg+xml;base64"); + expect(atob(turtleOutputs[0].content as string)).toContain("