feat(sdk): add op-web-sdk read-only web viewer crate

Plan 1 of the web embedding SDK (TS-retirement Phase 2): a wasm Viewer that
parses a .op document, renders it read-only via CanvasKit by reusing
op-editor-ui's canvas_viewport, supports pan/zoom navigation, exposes
read-only JSON snapshots, and exports SVG. Type-gen reuses jian-ops-schema's
ts-rs export.

Additive: new crate plus surgical cold pub exposures (CanvasViewport::from_scene
in op-editor-ui; pub mod canvaskit + pub init_backend in op-host-web). No TS
deleted. 17 tests; wasm 2.2 MiB gzip (0 env.* imports); clippy -D warnings clean.
This commit is contained in:
Kayshen-X 2026-06-19 17:52:12 +08:00
parent 70e6e76e29
commit 75ec668463
25 changed files with 1478 additions and 2 deletions

108
.github/workflows/web-sdk-bundle.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: op-web-sdk bundle build + size gate
# Builds the op-web-sdk WASM package (canvaskit feature), asserts 0 env.*
# imports and gzip size <= 6 MiB, then uploads pkg/ as the `op-web-sdk-bundle`
# artifact. This is the CI counterpart of the local
# `crates/op-web-sdk/tools/build-wasm.sh` developer gate.
#
# Mirrors `wasm-bundle-build.yml` (op-host-web gate) verbatim except for the
# crate path, artifact name, and env-var ceiling override name. In particular,
# this job does NOT use wasm-pack: wasm-pack 0.13.1 is broken on stable Rust
# (schema mismatch) and introduces a floating binary install risk. Instead we
# use the same cargo + wasm-bindgen-cli (version-pinned from Cargo.lock) +
# wasm-opt pipeline that `wasm-bundle-build.yml` uses.
#
# Authoritative recipe mirrored from `crates/op-web-sdk/tools/build-wasm.sh`:
# prerequisites: cargo, wasm-bindgen, wasm-opt, node, gzip (NO wasm-pack, NO EMSDK)
# 1. cargo build -p op-web-sdk --target wasm32-unknown-unknown
# --features canvaskit --release
# 2. wasm-bindgen --target web --out-dir crates/op-web-sdk/pkg <target.wasm>
# 3. assert 0 env.* imports (LinkError guard)
# 4. wasm-opt -Oz with rustc-emitted WebAssembly feature flags, then
# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via
# OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES)
# The job calls the script directly — it is the single source of truth for the
# gate logic.
on:
pull_request:
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'crates/op-web-sdk/**'
- 'deny.toml'
- '.github/workflows/web-sdk-bundle.yml'
push:
branches: ['**']
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'crates/op-web-sdk/**'
- '.github/workflows/web-sdk-bundle.yml'
# Allow on-demand runs so a release can produce the artifact without a code
# change (e.g. to re-bundle against an updated toolchain).
workflow_dispatch:
jobs:
build-web-sdk-bundle:
name: build + size-gate op-web-sdk wasm bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.94'
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
shared-key: web-sdk-bundle
- name: Install wasm-bindgen-cli (pinned to the locked version)
run: |
# Pin the CLI to the same version as the `wasm-bindgen` crate in
# Cargo.lock so the generated JS glue matches the linked runtime —
# a version skew between the two causes a "schema version mismatch"
# panic at mount time. Read the locked version straight out of
# Cargo.lock (the [[package]] block whose name is exactly `wasm-bindgen`).
# This mirrors wasm-bundle-build.yml exactly (same awk extractor).
version="$(awk '/^name = "wasm-bindgen"$/{found=1; next} found && /^version = /{gsub(/[" ]/,"",$3); print $3; exit}' Cargo.lock)"
if [ -z "$version" ]; then
echo "::error::could not resolve wasm-bindgen version from Cargo.lock"
exit 1
fi
echo "Installing wasm-bindgen-cli $version"
cargo install wasm-bindgen-cli --version "$version" --locked
- name: Install binaryen (wasm-opt)
run: |
sudo apt-get update
sudo apt-get install -y binaryen
wasm-opt --version
- name: Verify node + gzip are present (script prerequisites)
run: |
node --version
gzip --version | head -n1
wasm-bindgen --version
# Single source of truth: run the crate-local gate script verbatim. It
# runs cargo build (canvaskit) -> wasm-bindgen --target web ->
# 0-env-import assert -> wasm-opt -Oz -> gzip size <= ceiling, and exits
# non-zero on any breach.
- name: Build + size-gate the op-web-sdk bundle
run: bash crates/op-web-sdk/tools/build-wasm.sh
- name: Upload op-web-sdk-bundle artifact
uses: actions/upload-artifact@v4
with:
name: op-web-sdk-bundle
# Upload the full wasm-pack output directory: wasm + JS glue + .d.ts.
path: crates/op-web-sdk/pkg
if-no-files-found: error
# Keep briefly so a downstream release / Docker build can pull it
# without rebuilding.
retention-days: 14

17
Cargo.lock generated
View file

@ -3390,6 +3390,23 @@ dependencies = [
"tokio",
]
[[package]]
name = "op-web-sdk"
version = "0.8.0"
dependencies = [
"console_error_panic_hook",
"jian-ops-schema",
"op-editor-core",
"op-editor-ui",
"op-host-web",
"op-pen-loader",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"

View file

@ -387,6 +387,37 @@ impl<'a> CanvasViewport<'a> {
frame_labels: collect_frame_labels(state),
}
}
/// Read-only construction for the embedding SDK: paints `scene` at
/// `viewport` with no selection / tool / editing affordances. Frame
/// labels are derived from the scene's top-level node names rather
/// than an `EditorState`, so no editor is required to render.
///
/// All editor-specific fields (selection, pen draft, text-edit,
/// hover, guides) are left empty/default so the widget paints a
/// clean viewer layer — no overlays, no interactive affordances.
pub fn from_scene(scene: &'a LayoutScene, viewport: DocViewport, theme: Theme) -> Self {
let canvas_background = theme.canvas_surface;
Self {
id: WidgetId::new(4000),
viewport,
scene,
selected: String::new(),
selected_set: Vec::new(),
tool: op_editor_core::Tool::Select,
pen_in_progress: None,
pen_cursor_doc: None,
pen_dragging_handle: false,
active_guides: Vec::new(),
text_editing: None,
text_edit_input: Default::default(),
canvas_background,
theme,
now_ms: 0,
hovered: None,
frame_labels: Vec::new(),
}
}
}
/// Root-frame name labels (TS `drawFrameLabelColored` over
@ -720,3 +751,7 @@ pub(super) fn paint_dashed_rect(cx: &mut PaintCx<'_>, rect: Rect, color: Color,
#[cfg(test)]
#[path = "canvas_viewport_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "canvas_viewport_from_scene_tests.rs"]
mod from_scene_tests;

View file

@ -0,0 +1,98 @@
//! Gate spike test: proves `CanvasViewport::from_scene` can paint from a
//! `LayoutScene` + `DocViewport` without an `EditorState`.
//!
//! Step 1 (RED): run `cargo test -p op-editor-ui from_scene_paints -- --nocapture`
//! and confirm FAIL because `from_scene` is not yet defined.
//!
//! Step 4 (GREEN): after `from_scene` is added, the same run must PASS.
use crate::layout_scene::{LayoutScene, ScenePage};
use crate::theme::Theme;
use crate::widgets::canvas_viewport::CanvasViewport;
use crate::widgets::{PaintCx, Widget};
use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
use op_editor_core::Viewport as DocViewport;
// ---------------------------------------------------------------------------
// Counting backend — records fill_rect calls to verify paint happened.
// ---------------------------------------------------------------------------
#[derive(Default)]
struct CaptureBackend {
fill_rect_calls: usize,
}
impl CaptureBackend {
fn new(_width: u32, _height: u32) -> Self {
Self::default()
}
fn fill_rect_count(&self) -> usize {
self.fill_rect_calls
}
fn paint_cx(&mut self) -> PaintCx<'_> {
PaintCx {
backend: self,
}
}
}
impl RenderBackend for CaptureBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _rect: Rect, _color: Color) {
self.fill_rect_calls += 1;
}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
// ---------------------------------------------------------------------------
// Gate spike test
// ---------------------------------------------------------------------------
#[test]
fn from_scene_paints_background_and_nodes_without_editor_state() {
let scene = LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "Page".into(),
children: vec![],
}],
active_page_index: 0,
};
let vp = DocViewport {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
};
let view = CanvasViewport::from_scene(&scene, vp, Theme::dark());
let mut backend = CaptureBackend::new(800, 600);
{
let mut cx = backend.paint_cx();
view.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0));
}
assert!(
backend.fill_rect_count() >= 1,
"expected at least the canvas background fill, got {}",
backend.fill_rect_count()
);
}

View file

@ -604,7 +604,7 @@ impl RenderBackend for CanvasKitBackend {
}
/// Initialise CanvasKit on `canvas_id` and return a ready `CanvasKitBackend`.
pub(crate) async fn init_backend(
pub async fn init_backend(
canvas_id: &str,
dpr: f32,
logical_w: u32,

View file

@ -19,7 +19,7 @@
// stub baseline (compile coverage only).
mod a11y_dom;
#[cfg(feature = "canvaskit")]
mod canvaskit;
pub mod canvaskit;
pub mod event;
// Hidden IME-capture input (#54). Pure web_sys — compiles under BOTH the
// production `canvaskit` build (where the mount wires composition→apply_ime)

2
crates/op-web-sdk/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/pkg/
/bindings/

View file

@ -0,0 +1,48 @@
[package]
name = "op-web-sdk"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "OpenPencil read-only web embedding SDK — wasm Viewer (parse + render + navigate + export)"
[lib]
name = "op_web_sdk"
crate-type = ["cdylib", "rlib"]
[dependencies]
op-editor-ui = { path = "../op-editor-ui" }
op-editor-core = { path = "../op-editor-core" }
op-pen-loader = { path = "../op-pen-loader", default-features = false }
jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
# Pin matches op-host-web — last wasm-bindgen-cli release compatible with
# the workspace's Rust 1.85 toolchain.
wasm-bindgen = "=0.2.117"
console_error_panic_hook = "0.1"
# CanvasKit render backend — reuse op-host-web's CanvasKitBackend + init_backend
# so op-web-sdk never re-declares the op_ck_bridge.js JS module binding.
op-host-web = { path = "../op-host-web", optional = true, default-features = false, features = ["canvaskit"] }
# Async rAF helpers for the RAF loop
wasm-bindgen-futures = { version = "0.4", optional = true }
[dependencies.web-sys]
version = "0.3"
optional = true
features = [
"Document",
"Element",
"HtmlCanvasElement",
"Window",
]
[features]
default = []
# `canvaskit` enables the CanvasKit GPU render path via op-host-web.
canvaskit = [
"dep:op-host-web",
"dep:wasm-bindgen-futures",
"dep:web-sys",
]

View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>op-web-sdk smoke test</title>
<style>
body { margin: 0; background: #111; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
h1 { color: #ddd; font-family: sans-serif; margin-bottom: 12px; }
canvas { border: 1px solid #333; display: block; }
#status { color: #aaa; font-family: monospace; margin-top: 10px; font-size: 13px; }
</style>
</head>
<body>
<h1>OpenPencil Web SDK — smoke</h1>
<!--
Canvas element: 800×600 logical pixels.
wasm-pack build command:
wasm-pack build crates/op-web-sdk --target web --features canvaskit --out-dir smoke/pkg
Then serve: python3 -m http.server 8080 from crates/op-web-sdk/
Open: http://localhost:8080/smoke/index.html
-->
<canvas id="c" width="800" height="600" style="width:800px;height:600px;"></canvas>
<div id="status">Loading WASM…</div>
<!-- Minimal fixture: a single-page .op document with one red rectangle. -->
<script id="fixture" type="application/json">
{
"version": "1.0",
"pages": [
{
"id": "page-1",
"name": "Page 1",
"children": [
{
"id": "rect-1",
"kind": "rectangle",
"name": "Red Box",
"x": 100,
"y": 100,
"width": 200,
"height": 120,
"fillColor": "#e84040",
"strokeColor": null,
"strokeWidth": 0
}
]
}
]
}
</script>
<script type="module">
import init, { Viewer } from './pkg/op_web_sdk.js';
const status = document.getElementById('status');
try {
// 1. Initialise the wasm module.
await init();
status.textContent = 'WASM initialised — loading document…';
// 2. Construct the viewer and load the fixture document.
const viewer = new Viewer();
const fixture = document.getElementById('fixture').textContent;
viewer.load_str(fixture);
status.textContent = `Document loaded — pages: ${viewer.page_count()} — attaching canvas…`;
// 3. Attach the canvas and start the rAF render loop.
await viewer.attach_canvas('c');
status.textContent = `Rendering page 1 of ${viewer.page_count()} via CanvasKit ✓`;
} catch (err) {
status.textContent = `ERROR: ${err}`;
console.error(err);
}
</script>
</body>
</html>

View file

@ -0,0 +1,36 @@
// Document parsing and page snapshot accessors for Viewer.
use crate::Viewer;
use wasm_bindgen::prelude::wasm_bindgen;
impl Viewer {
/// Parse a canonical `.op` JSON string (best-effort, legacy-tolerant).
/// Immediately rebuilds the cached `LayoutScene` from the loaded document.
/// Internal Rust API; the JS-facing wrapper is `Viewer::load_str` (maps the
/// error to a `JsValue` so it surfaces as a thrown exception).
pub fn load(&mut self, src: &str) -> Result<(), String> {
let loaded = op_pen_loader::load_canonical(src).map_err(|e| format!("{e:?}"))?;
self.doc = Some(loaded.value);
self.active_page = 0;
self.rebuild_scene();
Ok(())
}
}
/// Read-only page accessors. Exported to JS so consumers (and the smoke page)
/// can query the loaded document without a full snapshot round-trip.
#[wasm_bindgen]
impl Viewer {
/// Number of pages in the loaded document.
/// Returns 0 when no document is loaded; at least 1 otherwise.
pub fn page_count(&self) -> usize {
self.doc
.as_ref()
.map(|d| d.pages.as_ref().map(|p| p.len()).unwrap_or(1).max(1))
.unwrap_or(0)
}
/// Index of the currently active (visible) page (0-based).
pub fn active_page_index(&self) -> usize {
self.active_page
}
}

View file

@ -0,0 +1,11 @@
// Tests for Viewer document parsing and page snapshot accessors.
const DOC: &str =
r#"{"version":"1.0","pages":[{"id":"p1","name":"Page 1","children":[]}]}"#;
#[test]
fn load_parses_pages() {
let mut v = super::Viewer::placeholder();
v.load(DOC).expect("parse");
assert_eq!(v.page_count(), 1);
assert_eq!(v.active_page_index(), 0);
}

View file

@ -0,0 +1,39 @@
// SVG (and future format) export for the read-only Viewer.
//
// Only SVG is available in standalone v1. PNG / PDF require a rendering
// daemon; calling `export("png")` returns an explicit error message so
// callers get a clear signal rather than a silent panic or opaque failure.
use crate::Viewer;
use wasm_bindgen::prelude::*;
impl Viewer {
/// Serialize the active page to an SVG string.
///
/// Returns `Err` when no document has been loaded, `rebuild_scene` has
/// not been called yet, or the scene has no renderable content.
pub fn export_svg(&self) -> Result<String, String> {
let scene = self
.scene
.as_ref()
.ok_or_else(|| "no scene — call load() then rebuild_scene() first".to_string())?;
op_editor_ui::svg_export::serialize_active_page_svg(scene)
}
}
/// Export the active page in the requested format.
///
/// `"svg"` — returns UTF-8 SVG bytes.
/// `"png"` / `"pdf"` — returns an explicit error; use a daemon for raster/PDF.
#[wasm_bindgen]
pub fn export(viewer: &Viewer, format: String) -> Result<Vec<u8>, JsValue> {
match format.to_lowercase().as_str() {
"svg" => viewer
.export_svg()
.map(|s| s.into_bytes())
.map_err(|e| JsValue::from_str(&e)),
_ => Err(JsValue::from_str(
"format not available in standalone v1; use SVG or a daemon",
)),
}
}

View file

@ -0,0 +1,17 @@
// Tests for Viewer::export_svg and the wasm `export` dispatcher.
#[test]
fn export_svg_emits_svg_root() {
let mut v = super::Viewer::placeholder();
v.load(r##"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":10,"height":10,"fill":[{"type":"solid","color":"#ff0000"}]}]}]}"##).unwrap();
v.rebuild_scene();
let svg = v.export_svg().expect("svg");
assert!(svg.trim_start().starts_with("<svg"));
}
#[test]
fn export_svg_fails_without_scene() {
let v = super::Viewer::placeholder();
let result = v.export_svg();
assert!(result.is_err());
}

View file

@ -0,0 +1,94 @@
//! OpenPencil read-only web embedding SDK. Parses a `.op` document and
//! renders it to a `<canvas>` via CanvasKit, with pan/zoom navigation,
//! read-only snapshots, and SVG export. No editing.
mod viewer_host;
mod document;
mod scene;
mod navigation;
mod export;
mod snapshot;
#[cfg(feature = "canvaskit")]
mod render;
use op_editor_core::Viewport as DocViewport;
use wasm_bindgen::prelude::*;
/// Read-only viewer handle. Owns the parsed document + paint scene.
#[wasm_bindgen]
pub struct Viewer {
/// Parsed canonical `.op` document; `None` until `load` is called.
doc: Option<jian_ops_schema::PenDocument>,
/// Index of the currently visible page (0-based).
active_page: usize,
/// Paint-only layout scene built from the loaded document.
/// `None` until a document is loaded and `rebuild_scene` runs.
scene: Option<op_editor_ui::layout_scene::LayoutScene>,
/// Current pan/zoom state. Defaults to identity (origin pan, 100% zoom).
/// Mutated by `set_viewport`, `zoom_to_fit`, and `forward_wheel`.
viewport: DocViewport,
}
#[wasm_bindgen]
impl Viewer {
/// Construct an empty viewer (no document loaded yet).
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Viewer {
doc: None,
active_page: 0,
scene: None,
viewport: DocViewport::IDENTITY,
}
}
/// Parse a canonical `.op` JSON string and render. Wraps `load` for JS.
pub fn load_str(&mut self, src: &str) -> Result<(), JsValue> {
self.load(src).map_err(|e| JsValue::from_str(&e))
}
}
impl Default for Viewer {
fn default() -> Self {
Self::new()
}
}
/// Test-only helpers — not part of the wasm public surface.
#[cfg(test)]
impl Viewer {
/// Construct an empty viewer for in-crate tests. Identical to `new()`;
/// kept as a named alias so test call sites don't need `Default::default()`.
pub(crate) fn placeholder() -> Self {
Self::new()
}
}
/// No-op `mark_dirty` when the canvaskit render path is not compiled in.
/// The real implementation lives in `render.rs` (feature = "canvaskit").
#[cfg(not(feature = "canvaskit"))]
impl Viewer {
pub(crate) fn mark_dirty(&self) {}
}
#[cfg(test)]
mod scaffold_tests {
#[test]
fn viewer_placeholder_constructs() {
let _v = super::Viewer::placeholder();
}
}
#[cfg(test)]
mod document_tests;
#[cfg(test)]
mod scene_tests;
#[cfg(test)]
mod navigation_tests;
#[cfg(test)]
mod export_tests;
#[cfg(test)]
mod snapshot_tests;

View file

@ -0,0 +1,109 @@
//! Pan / zoom navigation for the read-only Viewer.
//!
//! Pure viewport state management (no browser dependencies) so the math is
//! unit-testable without the `canvaskit` feature. The wheel handler that
//! integrates with the render state lives in the `canvaskit` feature block at
//! the bottom of this file.
use op_editor_core::Viewport as DocViewport;
use op_editor_ui::{Point2D, Rect};
use crate::Viewer;
// ---------------------------------------------------------------------------
// Core viewport accessors (feature-independent).
// ---------------------------------------------------------------------------
impl Viewer {
/// Set the pan/zoom state and push the update into the live render state.
pub fn set_viewport(&mut self, pan_x: f32, pan_y: f32, zoom: f32) {
self.viewport = DocViewport { pan_x, pan_y, zoom };
// Push to the RAF pump so it repaints with the new viewport.
// mark_dirty is redundant here (push_viewport sets the dirty flag),
// but kept as a belt-and-suspenders no-op for the non-canvaskit path.
self.push_viewport();
self.mark_dirty();
}
/// Return the current viewport as `(pan_x, pan_y, zoom)`.
pub fn viewport(&self) -> (f32, f32, f32) {
(self.viewport.pan_x, self.viewport.pan_y, self.viewport.zoom)
}
/// Fit the active page's content into a `w × h` canvas (in CSS px).
///
/// Computes the union AABB of all top-level nodes on the active page
/// (via `LayoutScene::content_bounds`), then calls `Viewport::fit_to`
/// to centre and scale the content within the canvas with a 40 px
/// padding margin. Updates `self.viewport` and pushes to the live render
/// state so the RAF pump picks up the new pan/zoom on the next frame.
/// A no-op when no scene is loaded or the page has no nodes with
/// positive size.
pub fn zoom_to_fit(&mut self, w: f32, h: f32) {
let Some(scene) = self.scene.as_ref() else {
return;
};
let Some(bounds) = scene.content_bounds() else {
return;
};
// Guard: content must have positive area.
if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 {
return;
}
self.viewport.fit_to(
Rect {
origin: Point2D::new(bounds.origin.x, bounds.origin.y),
size: Point2D::new(bounds.size.x, bounds.size.y),
},
w,
h,
40.0,
);
// Push to the RAF pump so it repaints with the fitted viewport.
self.push_viewport();
self.mark_dirty();
}
}
// ---------------------------------------------------------------------------
// No-op push_viewport stub for builds without the canvaskit render path.
// Mirrors the mark_dirty no-op pattern in lib.rs.
// ---------------------------------------------------------------------------
#[cfg(not(feature = "canvaskit"))]
impl Viewer {
pub(crate) fn push_viewport(&self) {}
}
// ---------------------------------------------------------------------------
// Wheel + push_viewport — browser-only (canvaskit feature).
// ---------------------------------------------------------------------------
#[cfg(feature = "canvaskit")]
impl Viewer {
/// Process a DOM `wheel` event from JS.
///
/// When `ctrl_or_meta` is true (pinch-to-zoom or Ctrl+wheel) the event
/// zooms about `(cursor_x, cursor_y)` in canvas-local px. Otherwise it
/// pans by `(dx, dy)` CSS px (typical scroll or two-finger pan).
///
/// After updating the viewport, the new state is pushed into the live
/// render state so the RAF pump picks it up on the next animation frame.
pub fn forward_wheel(&mut self, dx: f32, dy: f32, ctrl_or_meta: bool, cursor_x: f32, cursor_y: f32) {
if ctrl_or_meta {
// Zoom about cursor. Negate dy so scroll-up zooms in.
self.viewport.zoom_at(Point2D::new(cursor_x, cursor_y), -dy);
} else {
// Pan in the scroll direction.
self.viewport.pan(-dx, -dy);
}
self.push_viewport();
}
/// Push the current `self.viewport` into the live `RenderInner` so the
/// RAF pump reads the updated value on the next frame.
pub(crate) fn push_viewport(&self) {
use crate::render::push_viewport_to_render;
push_viewport_to_render(self.viewport);
}
}

View file

@ -0,0 +1,72 @@
// Navigation tests — pure math, no `canvaskit` feature required.
#[test]
fn zoom_to_fit_sets_nonzero_zoom_for_loaded_doc() {
let mut v = super::Viewer::placeholder();
v.load(r#"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":100,"height":50}]}]}"#).unwrap();
v.rebuild_scene();
v.zoom_to_fit(800.0, 600.0);
let (_, _, zoom) = v.viewport();
assert!(zoom > 0.0);
}
#[test]
fn set_viewport_round_trips() {
let mut v = super::Viewer::placeholder();
v.set_viewport(10.0, -20.0, 2.5);
let (px, py, z) = v.viewport();
assert!((px - 10.0).abs() < 1e-5);
assert!((py + 20.0).abs() < 1e-5);
assert!((z - 2.5).abs() < 1e-5);
}
#[test]
fn zoom_to_fit_noop_when_no_scene() {
let mut v = super::Viewer::placeholder();
// No doc loaded — should not panic and zoom stays at 1.0 (identity).
v.zoom_to_fit(800.0, 600.0);
let (_, _, zoom) = v.viewport();
assert!((zoom - 1.0).abs() < 1e-5);
}
/// Regression test: set_viewport must be visible via viewport() after the call.
/// The push_viewport path is canvaskit-gated; without canvaskit the push_viewport
/// stub is a no-op, but the Viewer's own viewport field is always updated.
#[test]
fn set_viewport_reflected_by_accessor() {
let mut v = super::Viewer::placeholder();
v.set_viewport(50.0, 75.0, 1.5);
let (px, py, z) = v.viewport();
assert!((px - 50.0).abs() < 1e-5, "pan_x must be reflected");
assert!((py - 75.0).abs() < 1e-5, "pan_y must be reflected");
assert!((z - 1.5).abs() < 1e-5, "zoom must be reflected");
// Note: the push to RenderInner.viewport (so the RAF pump renders the new
// viewport) is tested via the canvaskit integration path; here we verify
// that the Viewer-side state is updated and the push path does not panic.
}
/// Finding (3): zoom_to_fit must be a no-op (no panic, identity viewport)
/// when the loaded document's only node has zero size.
#[test]
fn zoom_to_fit_noop_for_zero_size_content() {
let mut v = super::Viewer::placeholder();
// Node with 0×0 size — content_bounds yields zero-area AABB.
v.load(r#"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":0,"height":0}]}]}"#).unwrap();
v.rebuild_scene();
let (pan_x_before, pan_y_before, zoom_before) = v.viewport();
v.zoom_to_fit(800.0, 600.0);
let (pan_x_after, pan_y_after, zoom_after) = v.viewport();
// Viewport must be unchanged when content has no positive area.
assert!(
(pan_x_after - pan_x_before).abs() < 1e-5,
"pan_x must not change for zero-size content"
);
assert!(
(pan_y_after - pan_y_before).abs() < 1e-5,
"pan_y must not change for zero-size content"
);
assert!(
(zoom_after - zoom_before).abs() < 1e-5,
"zoom must not change for zero-size content"
);
}

View file

@ -0,0 +1,269 @@
//! CanvasKit render loop for the read-only `Viewer`.
//!
//! `attach_canvas` initialises a `CanvasKitBackend` from `op-host-web` and
//! installs a `requestAnimationFrame` pump that repaints the document scene
//! whenever the `dirty` flag is set. The widget facade is entirely delegated
//! to `viewer_host::paint_scene` — this file owns only the lifecycle glue.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use op_editor_core::Viewport as DocViewport;
use op_editor_ui::theme::Theme;
use op_editor_ui::RenderBackend;
use op_host_web::canvaskit::{CanvasKitBackend, init_backend};
use crate::Viewer;
// ---------------------------------------------------------------------------
// Per-instance render state.
// ---------------------------------------------------------------------------
/// Live render state for one mounted Viewer.
struct RenderInner {
backend: CanvasKitBackend,
/// Physical width of the canvas element (CSS pixels).
logical_w: f32,
/// Physical height of the canvas element (CSS pixels).
logical_h: f32,
/// Set to `true` after any state change that requires a repaint.
dirty: Cell<bool>,
/// The most recent layout scene to paint. Updated by `push_scene` so
/// `load_str()` + `mark_dirty()` shows the newly loaded document rather
/// than the stale snapshot captured at `attach_canvas` time.
scene: Option<op_editor_ui::layout_scene::LayoutScene>,
/// Current pan/zoom viewport. Updated by `push_viewport_to_render` on
/// every `set_viewport` / `forward_wheel` call so the pump always reads
/// the live value rather than the snapshot captured at attach time.
viewport: DocViewport,
}
// ---------------------------------------------------------------------------
// Thread-local render slot — one Viewer can be mounted at a time.
// ---------------------------------------------------------------------------
thread_local! {
/// The active render state. `None` until `attach_canvas` succeeds.
static RENDER: RefCell<Option<Rc<RefCell<RenderInner>>>> = const { RefCell::new(None) };
}
// ---------------------------------------------------------------------------
// wasm-bindgen impl on Viewer
// ---------------------------------------------------------------------------
#[wasm_bindgen]
impl Viewer {
/// Attach a `<canvas>` element and start the rAF render loop.
///
/// Reads the canvas's current pixel dimensions to set the logical viewport.
/// Subsequent calls to `mark_dirty()` (or an internal repaint trigger)
/// will cause a repaint on the next animation frame.
///
/// If a previous rAF pump is running it is stopped before the new one
/// starts: clearing the `RENDER` thread-local slot causes the old pump to
/// self-terminate on its next tick, so only one pump is ever live.
pub async fn attach_canvas(&self, canvas_id: String) -> Result<(), JsValue> {
// Tear down any existing render state so the previous rAF pump sees an
// empty slot on its next tick and terminates, preventing two concurrent
// pumps from racing on begin_frame / end_frame.
self.detach();
console_error_panic_hook::set_once();
let window =
web_sys::window().ok_or_else(|| JsValue::from_str("attach_canvas: no window"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("attach_canvas: no document"))?;
let canvas: web_sys::HtmlCanvasElement = document
.get_element_by_id(&canvas_id)
.ok_or_else(|| JsValue::from_str("attach_canvas: canvas not found"))?
.dyn_into()
.map_err(|_| JsValue::from_str("attach_canvas: element is not a <canvas>"))?;
// Derive logical size from the canvas element's current CSS client area.
let css_w = canvas.client_width().max(1) as u32;
let css_h = canvas.client_height().max(1) as u32;
// Use a device-pixel-ratio of 1.0 when the canvas has no physical size
// set yet; the caller can resize the element and call again.
let dev_w = canvas.width().max(1) as f32;
let css_w_f = css_w as f32;
let dpr = (dev_w / css_w_f).max(1.0);
let backend = init_backend(&canvas_id, dpr, css_w, css_h).await?;
// Snapshot the current scene and viewport into the shared state so
// the pump can paint immediately. Subsequent `load_str` + `push_scene`
// and `set_viewport` / `forward_wheel` calls update the fields without
// re-attaching the canvas.
let initial_scene = self.scene().cloned();
let initial_viewport = self.viewport;
let inner = Rc::new(RefCell::new(RenderInner {
backend,
logical_w: css_w as f32,
logical_h: css_h as f32,
dirty: Cell::new(true),
scene: initial_scene,
viewport: initial_viewport,
}));
// Install the render slot so the rAF closure can reach it.
RENDER.with(|slot| {
*slot.borrow_mut() = Some(inner.clone());
});
let inner_for_raf = inner.clone();
start_raf_pump(inner_for_raf);
Ok(())
}
/// Detach the CanvasKit backend, stopping the rAF loop.
///
/// Safe to call when not attached — it is a no-op in that case.
pub fn detach(&self) {
RENDER.with(|slot| {
slot.borrow_mut().take();
});
}
/// Mark the canvas dirty so the rAF pump issues a repaint on the next
/// animation frame.
///
/// After calling `load_str()` call `push_scene()` (which also sets the
/// dirty flag) to update both the displayed scene and trigger a repaint.
/// `mark_dirty()` alone repaints the currently stored scene without
/// replacing it.
pub fn mark_dirty(&self) {
RENDER.with(|slot| {
if let Some(inner_rc) = slot.borrow().as_ref() {
if let Ok(inner) = inner_rc.try_borrow() {
inner.dirty.set(true);
}
}
});
}
/// Push the viewer's current layout scene into the live render state and
/// mark the canvas dirty.
///
/// Call this after `load_str()` to display the newly loaded document
/// without re-attaching the canvas. If no canvas is attached yet, this
/// is a no-op; the scene will be picked up automatically by the next
/// `attach_canvas` call via the `initial_scene` snapshot.
pub fn push_scene(&self) {
let scene = self.scene().cloned();
RENDER.with(|slot| {
if let Some(inner_rc) = slot.borrow().as_ref() {
if let Ok(mut inner) = inner_rc.try_borrow_mut() {
inner.scene = scene;
inner.dirty.set(true);
}
}
});
}
}
// ---------------------------------------------------------------------------
// rAF pump helpers.
// ---------------------------------------------------------------------------
/// Push an updated viewport into the live render state so the RAF pump reads
/// it on the next animation frame. Called from `navigation::push_viewport`.
/// No-op if no canvas is attached.
pub(crate) fn push_viewport_to_render(vp: DocViewport) {
RENDER.with(|slot| {
if let Some(inner_rc) = slot.borrow().as_ref() {
if let Ok(mut inner) = inner_rc.try_borrow_mut() {
inner.viewport = vp;
inner.dirty.set(true);
}
}
});
}
/// Schedule a self-rescheduling `requestAnimationFrame` pump.
///
/// The pump fires once per animation frame, checks the `dirty` flag, paints
/// if needed, then reschedules as long as the RENDER thread-local is occupied.
/// When `detach` clears the slot the next frame fires, finds the slot empty,
/// and drops the closure.
///
/// The scene is read from `inner.scene` each frame so callers can update it
/// via `push_scene` without restarting the pump.
fn start_raf_pump(inner: Rc<RefCell<RenderInner>>) {
// Wrap the closure in a shared slot so it can re-schedule itself.
type FrameSlot = Rc<RefCell<Option<Closure<dyn FnMut()>>>>;
let holder: FrameSlot = Rc::new(RefCell::new(None));
let holder2 = holder.clone();
*holder.borrow_mut() = Some(Closure::wrap(Box::new(move || {
// Check whether the render slot is still live. If `detach` was
// called (or a new `attach_canvas` replaced the slot) the Rc inside
// RENDER is a *different* allocation from the one captured in
// `inner`, so the pump can no longer reach the active state — stop.
let still_live = RENDER.with(|slot| {
slot.borrow()
.as_ref()
.map(|rc| Rc::ptr_eq(rc, &inner))
.unwrap_or(false)
});
if !still_live {
// Drop the self-reference so the closure can be freed.
let _ = holder2.borrow_mut().take();
return;
}
// Repaint if dirty.
if let Ok(mut b) = inner.try_borrow_mut() {
if b.dirty.get() {
// Clone the scene out to avoid holding the borrow across the
// mutable backend calls (which borrow `b` mutably).
if let Some(scene_snap) = b.scene.clone() {
// Read the live viewport — updated by push_viewport_to_render
// on each set_viewport / forward_wheel call.
let vp = b.viewport;
let w = b.logical_w;
let h = b.logical_h;
b.backend.begin_frame();
crate::viewer_host::paint_scene(
&mut b.backend,
&scene_snap,
vp,
Theme::dark(),
w,
h,
);
b.backend.end_frame();
b.dirty.set(false);
}
}
}
// Reschedule for the next frame.
if let Some(c) = holder2.borrow().as_ref() {
request_frame(c);
}
}) as Box<dyn FnMut()>));
// Kick off the first frame.
{
let slot = holder.borrow();
if let Some(c) = slot.as_ref() {
request_frame(c);
}
}
}
/// Schedule `c` to run on the next animation frame. Best-effort: if `window`
/// is unavailable (e.g. a WebWorker) the pump simply does not run.
fn request_frame(c: &Closure<dyn FnMut()>) {
if let Some(window) = web_sys::window() {
let _ = window.request_animation_frame(c.as_ref().unchecked_ref());
}
}

View file

@ -0,0 +1,30 @@
// Paint-only LayoutScene builder for Viewer.
// Delegates to op_pen_loader::pen_document_to_layout_scene which runs
// jian-core's taffy flex layout pass (using the estimate measure backend
// when the skia-measure feature is disabled, which is the case here).
use crate::Viewer;
use std::collections::BTreeMap;
impl Viewer {
/// Rebuild the paint-only scene for the active page from the loaded doc.
/// Clears the scene if no document is loaded.
pub fn rebuild_scene(&mut self) {
let Some(doc) = self.doc.as_ref() else {
self.scene = None;
return;
};
// v1: pass an empty active-theme map (default theme axis).
let active_theme: BTreeMap<String, String> = BTreeMap::new();
self.scene = Some(op_pen_loader::pen_document_to_layout_scene(
doc,
&active_theme,
self.active_page,
));
}
/// Return a reference to the cached layout scene, or `None` if no
/// document has been loaded or `rebuild_scene` has not been called.
pub fn scene(&self) -> Option<&op_editor_ui::layout_scene::LayoutScene> {
self.scene.as_ref()
}
}

View file

@ -0,0 +1,11 @@
// Tests for Viewer::rebuild_scene / scene().
const DOC: &str = r#"{"version":"1.0","pages":[{"id":"p1","name":"P","children":[{"type":"rectangle","id":"r1","x":0,"y":0,"width":10,"height":10}]}]}"#;
#[test]
fn rebuild_scene_yields_one_page() {
let mut v = super::Viewer::placeholder();
v.load(DOC).unwrap();
v.rebuild_scene();
let scene = v.scene().expect("scene built");
assert_eq!(scene.pages.len(), 1);
}

View file

@ -0,0 +1,125 @@
// Read-only JSON snapshot getters for the embedded Viewer.
//
// These methods serialize the current in-memory state to JSON strings so
// JavaScript callers can read document structure without touching Rust internals.
// All methods are available without the `canvaskit` feature (they use serde_json
// directly, with no browser dependencies).
//
// Error handling: all three getters return `Result<String, JsValue>`. On the
// JS side wasm-bindgen converts `Err(JsValue)` into a thrown exception, so
// serialisation failures surface as real JS errors rather than fake-JSON
// strings that `JSON.parse` would silently swallow.
use crate::Viewer;
use jian_ops_schema::node::PenNode;
use op_editor_core::Viewport as DocViewport;
use serde::Serialize;
use wasm_bindgen::prelude::*;
// ---------------------------------------------------------------------------
// Viewport serialization helper — DocViewport does not derive Serialize so we
// project its fields into a small local struct that does.
// ---------------------------------------------------------------------------
#[derive(Serialize)]
#[allow(dead_code)]
struct ViewportSnapshot {
pan_x: f32,
pan_y: f32,
zoom: f32,
}
impl From<DocViewport> for ViewportSnapshot {
fn from(v: DocViewport) -> Self {
ViewportSnapshot { pan_x: v.pan_x, pan_y: v.pan_y, zoom: v.zoom }
}
}
// ---------------------------------------------------------------------------
// Synthetic single-page wrapper for the single-page-fallback path.
//
// When `PenDocument.pages` is `None` but `children` is non-empty, the nodes
// live in `doc.children` (the legacy/single-page-fallback layout). We wrap
// them in a synthetic page so JS callers always receive the same
// `[{id, name, children}]` shape regardless of which storage format was used.
// ---------------------------------------------------------------------------
#[derive(Serialize)]
struct SyntheticPage<'a> {
id: &'a str,
name: &'a str,
children: &'a [PenNode],
}
// ---------------------------------------------------------------------------
// Wasm-exposed snapshot getters.
// ---------------------------------------------------------------------------
#[wasm_bindgen]
impl Viewer {
/// Serialize the full loaded `PenDocument` to a JSON string.
///
/// Returns `Ok("{}")` when no document has been loaded yet so callers always
/// receive valid JSON they can safely parse without null-checking.
///
/// # Errors
/// Throws a JS exception if `serde_json` fails to serialize the document.
pub fn document_json(&self) -> Result<String, JsValue> {
match &self.doc {
Some(doc) => serde_json::to_string(doc)
.map_err(|e| JsValue::from_str(&e.to_string())),
None => Ok("{}".to_string()),
}
}
/// Serialize the pages array of the loaded document to a JSON string.
///
/// The returned JSON always has the shape `[{id, name, children}, ...]`.
///
/// - No document loaded → `Ok("[]")`.
/// - Document has an explicit `pages` array → serialize that array.
/// - Document has `pages: None` but non-empty `children` (single-page
/// fallback) → return a synthetic single-element array wrapping those
/// children under `{id: "default", name: "Page 1", children: [...]}`.
/// - Document has `pages: None` and empty `children` → `Ok("[]")`.
///
/// # Errors
/// Throws a JS exception if `serde_json` fails to serialize the data.
pub fn pages_json(&self) -> Result<String, JsValue> {
let doc = match &self.doc {
Some(d) => d,
None => return Ok("[]".to_string()),
};
match &doc.pages {
Some(pages) => serde_json::to_string(pages)
.map_err(|e| JsValue::from_str(&e.to_string())),
None => {
if doc.children.is_empty() {
Ok("[]".to_string())
} else {
// Single-page fallback: wrap doc.children in a synthetic page.
let synthetic = [SyntheticPage {
id: "default",
name: "Page 1",
children: &doc.children,
}];
serde_json::to_string(&synthetic)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}
}
}
/// Serialize the current viewport (pan + zoom) to a JSON string.
///
/// The returned object always contains `pan_x`, `pan_y`, and `zoom` fields.
///
/// # Errors
/// Throws a JS exception if `serde_json` fails to serialize the viewport.
pub fn viewport_json(&self) -> Result<String, JsValue> {
let snap = ViewportSnapshot::from(self.viewport);
serde_json::to_string(&snap)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}

View file

@ -0,0 +1,72 @@
// Tests for Viewer read-only JSON snapshot getters.
// A document with an explicit pages array (multi-page format).
const DOC_WITH_PAGE: &str =
r#"{"version":"1.0","pages":[{"id":"pageX","name":"P","children":[]}]}"#;
// A document that uses the single-page fallback: no `pages` key, nodes live in
// top-level `children`. PenNode is a serde tagged enum so `type` is required.
const DOC_SINGLE_PAGE_FALLBACK: &str =
r#"{"version":"1.0","children":[{"type":"rectangle","id":"r1","name":"rect"}]}"#;
#[test]
fn document_json_contains_page_id() {
let mut v = super::Viewer::placeholder();
v.load(DOC_WITH_PAGE).unwrap();
assert!(v.document_json().unwrap().contains("pageX"));
}
#[test]
fn pages_json_contains_page_id() {
let mut v = super::Viewer::placeholder();
v.load(DOC_WITH_PAGE).unwrap();
assert!(v.pages_json().unwrap().contains("pageX"));
}
#[test]
fn viewport_json_round_trips() {
let mut v = super::Viewer::placeholder();
v.set_viewport(5.0, -10.0, 2.0);
let j = v.viewport_json().unwrap();
// Must contain the pan_x field and the exact values we set.
assert!(j.contains("pan_x"), "expected pan_x field in: {j}");
assert!(j.contains('5'), "expected pan_x value 5 in: {j}");
assert!(j.contains('2'), "expected zoom value 2 in: {j}");
}
#[test]
fn document_json_returns_empty_on_no_doc() {
let v = super::Viewer::placeholder();
// No document loaded — must return exactly "{}" (the documented default).
let j = v.document_json().unwrap();
assert_eq!(j, "{}", "document_json must return \"{{}}\" when no doc is loaded, got: {j}");
}
#[test]
fn pages_json_returns_empty_array_on_no_doc() {
let v = super::Viewer::placeholder();
assert_eq!(v.pages_json().unwrap(), "[]");
}
#[test]
fn pages_json_single_page_fallback_exposes_children() {
let mut v = super::Viewer::placeholder();
v.load(DOC_SINGLE_PAGE_FALLBACK).unwrap();
let j = v.pages_json().unwrap();
// Must be a non-empty JSON array containing our node.
assert!(j.starts_with('[') && !j.starts_with("[]"),
"expected non-empty array for single-page fallback, got: {j}");
assert!(j.contains("rectangle"), "expected node type value in pages_json result: {j}");
// Synthetic page must carry the documented sentinel fields.
assert!(j.contains("\"id\":\"default\""), "expected synthetic page id in: {j}");
assert!(j.contains("\"name\":\"Page 1\""), "expected synthetic page name in: {j}");
}
#[test]
fn pages_json_no_pages_no_children_returns_empty_array() {
// Document with neither pages nor children → "[]"
let src = r#"{"version":"1.0","children":[]}"#;
let mut v = super::Viewer::placeholder();
v.load(src).unwrap();
assert_eq!(v.pages_json().unwrap(), "[]");
}

View file

@ -0,0 +1,29 @@
//! The only module permitted to call `op_editor_ui::widgets::*` (widget-boundary rule).
//!
//! Provides the sole paint entry point for the read-only viewer. All widget
//! logic is contained here; `render.rs` calls through this module without
//! touching `op_editor_ui::widgets` directly.
//!
//! `paint_scene` is called only from the `canvaskit`-feature render path.
#![allow(dead_code)]
use op_editor_core::Viewport as DocViewport;
use op_editor_ui::layout_scene::LayoutScene;
use op_editor_ui::theme::Theme;
use op_editor_ui::widgets::canvas_viewport::CanvasViewport;
use op_editor_ui::widgets::{PaintCx, Widget};
use op_editor_ui::{Rect, RenderBackend};
// glue: read-only viewer paint pass.
pub fn paint_scene(
backend: &mut dyn RenderBackend,
scene: &LayoutScene,
viewport: DocViewport,
theme: Theme,
w: f32,
h: f32,
) {
let view = CanvasViewport::from_scene(scene, viewport, theme);
let mut cx = PaintCx { backend };
view.paint(&mut cx, Rect::xywh(0.0, 0.0, w, h));
}

View file

@ -0,0 +1,115 @@
#!/usr/bin/env bash
# crates/op-web-sdk/tools/build-wasm.sh — cargo + wasm-bindgen + wasm-opt
# build + bundle gate for op-web-sdk.
#
# What this script does:
# 1. Verify prerequisites: cargo, wasm-bindgen, wasm-opt, node, gzip.
# 2. cargo build -p op-web-sdk --target wasm32-unknown-unknown \
# --features canvaskit --release
# Note: --no-default-features is NOT needed — op-web-sdk's [features]
# default is empty (default = []), so there are no default features to
# suppress.
# 3. wasm-bindgen --target web → crates/op-web-sdk/pkg/
# Output: pkg/op_web_sdk_bg.wasm + pkg/op_web_sdk.js + pkg/*.d.ts.
# The artifact is the raw wasm-bindgen pkg/ directory — no separate
# CanvasKit sub-asset assembly is needed because op-web-sdk is pure
# document logic; CanvasKit itself is loaded externally by the host page.
# 4. Assert 0 env.* imports in the raw bindgen wasm (LinkError guard).
# 5. wasm-opt -Oz (with the rustc-emitted WebAssembly feature flags) then
# gzip size <= OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES (default 6 291 456 = 6 MiB).
# The SDK is pure logic with no CanvasKit WASM included — the ceiling
# mirrors op-host-web's 6 MiB guard and can be tightened later.
#
# This approach mirrors tools/check-wasm-bundle.sh (op-host-web gate) exactly:
# cargo build → wasm-bindgen → wasm-opt, without wasm-pack. This removes the
# wasm-pack version fragility (0.13.1 is broken on stable Rust) and keeps the
# two gates consistent.
#
# This script is the local counterpart to
# `.github/workflows/web-sdk-bundle.yml`; keep the two recipes aligned.
#
# Exit semantics:
# 0 all checks PASS.
# 1 any check FAILED — message names which one.
# 2 prerequisite missing.
set -euo pipefail
# Resolve workspace root regardless of where the script is invoked from.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
CRATE_DIR="${WORKSPACE_ROOT}/crates/op-web-sdk"
PKG_DIR="${CRATE_DIR}/pkg"
# wasm-bindgen names the .wasm after the lib.name in Cargo.toml ("op_web_sdk").
WASM_RAW="${PKG_DIR}/op_web_sdk_bg.wasm"
WASM_OPT="${PKG_DIR}/op_web_sdk_bg.opt.wasm"
# cargo places the raw cdylib here before wasm-bindgen post-processes it.
TARGET_WASM="${WORKSPACE_ROOT}/target/wasm32-unknown-unknown/release/op_web_sdk.wasm"
# rustc-emitted WebAssembly feature flags (matches op-host-web gate).
WASM_OPT_FEATURES=(
--enable-bulk-memory
--enable-bulk-memory-opt
--enable-nontrapping-float-to-int
)
# Gzip ceiling — same 6 MiB as op-host-web; the SDK bundle is pure logic.
# Override via env when intentionally re-baselining.
LIMIT="${OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES:-6291456}"
step() { printf '\n[step %d/%d] %s\n' "$1" "$2" "$3"; }
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || { printf 'missing prerequisite: %s\n' "$1" >&2; exit 2; }; }
step 1 5 "Verify prerequisites"
need cargo
need wasm-bindgen
need wasm-opt
need node
need gzip
step 2 5 "cargo build -p op-web-sdk --target wasm32-unknown-unknown --features canvaskit --release"
# Run from the workspace root so cargo resolves the workspace Cargo.toml.
# --no-default-features is omitted intentionally: op-web-sdk has no default
# features (default = [] in Cargo.toml), so the flag is a no-op and omitting
# it keeps the invocation consistent with what a consumer would normally use.
cd "${WORKSPACE_ROOT}"
cargo build -p op-web-sdk \
--target wasm32-unknown-unknown \
--features canvaskit \
--release
step 3 5 "wasm-bindgen --target web → ${PKG_DIR}/"
mkdir -p "${PKG_DIR}"
wasm-bindgen --target web --out-dir "${PKG_DIR}" "${TARGET_WASM}"
step 4 5 "Verify 0 env.* imports (LinkError guard)"
env_count="$(node -e '
const fs = require("fs");
const buf = fs.readFileSync(process.argv[1]);
WebAssembly.compile(buf).then(mod => {
const imps = WebAssembly.Module.imports(mod);
const env = imps.filter(i => i.module === "env");
console.log(env.length);
}).catch(e => { console.error("compile failed:", e); process.exit(1); });
' "${WASM_RAW}")"
if [ "${env_count}" != "0" ]; then
fail "env.* import count = ${env_count} (must be 0); a dep pulled non-wasm-clean code — find and gate it out"
fi
printf ' ✓ 0 env.* imports\n'
step 5 5 "wasm-opt -Oz + gzip size <= ${LIMIT} bytes"
wasm-opt "${WASM_OPT_FEATURES[@]}" -Oz "${WASM_RAW}" -o "${WASM_OPT}"
# Replace the raw wasm with the optimised version in-place so pkg/ is
# ready to deploy / upload as-is (mirrors check-wasm-bundle.sh behaviour).
cp "${WASM_OPT}" "${WASM_RAW}"
gz_bytes="$(gzip -c "${WASM_OPT}" | wc -c | tr -d ' ')"
if [ "${gz_bytes}" -gt "${LIMIT}" ]; then
fail "op-web-sdk wasm gzip size ${gz_bytes} bytes > ceiling ${LIMIT} bytes"
fi
pct=$(( (gz_bytes * 100) / LIMIT ))
printf ' ✓ gzip size %s bytes (%d%% of %s ceiling)\n' "${gz_bytes}" "${pct}" "${LIMIT}"
printf '\nAll op-web-sdk bundle gates PASS.\n'
printf 'Output: %s/\n' "${PKG_DIR}"

View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# gen-types.sh — emit TypeScript type definitions for PenDocument and related
# types by running jian-ops-schema's built-in export_ts binary.
#
# Output: crates/op-web-sdk/bindings/ops.ts (inside our crate, NOT the submodule)
#
# The jian-ops-schema export_ts binary writes into its own crate's bindings/
# directory (vendor/jian/crates/jian-ops-schema/bindings/) via a compile-time
# CARGO_MANIFEST_DIR path — it cannot be redirected by TS_RS_EXPORT_DIR at
# runtime. To keep vendor/jian clean we:
# 1. Run the binary (writes into vendor submodule).
# 2. Copy the generated file into crates/op-web-sdk/bindings/.
# 3. Restore vendor/jian to the pre-run state.
# After this script exits, `git -C vendor/jian status --short` must show no
# changes.
set -euo pipefail
WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
JIAN_ROOT="${WORKSPACE_ROOT}/vendor/jian"
JIAN_BINDINGS="${JIAN_ROOT}/crates/jian-ops-schema/bindings"
SDK_BINDINGS="${WORKSPACE_ROOT}/crates/op-web-sdk/bindings"
echo "Generating TypeScript bindings from jian-ops-schema (workspace: ${JIAN_ROOT})..."
# jian is excluded from the main workspace — run cargo inside its own workspace.
cargo run --manifest-path "${JIAN_ROOT}/Cargo.toml" \
-p jian-ops-schema \
--features export-ts \
--bin export_ts 2>&1
echo "Copying generated files to ${SDK_BINDINGS}..."
mkdir -p "${SDK_BINDINGS}"
# Copy all .ts files emitted by ts-rs into our crate's bindings directory.
cp "${JIAN_BINDINGS}"/*.ts "${SDK_BINDINGS}/"
echo "Restoring vendor/jian to pre-run state to keep the submodule clean..."
git -C "${JIAN_ROOT}" checkout -- .
echo "Done. TypeScript bindings available at ${SDK_BINDINGS}/"
echo "Verifying vendor/jian is clean..."
if git -C "${JIAN_ROOT}" status --short | grep -q .; then
echo "ERROR: vendor/jian still has uncommitted changes after restore!" >&2
git -C "${JIAN_ROOT}" status --short >&2
exit 1
fi
echo "vendor/jian is clean — OK."

View file

@ -179,6 +179,23 @@ if [ -n "${core_ref_hits}" ]; then
fi
fi
# ---------------------------------------------------------------------
# SDK-F4: only viewer_host.rs may import `op_editor_ui::widgets` in
# any form inside `crates/op-web-sdk/src/`. Mirrors the op-host-web
# F4 rule with the allowed module changed to `viewer_host`.
# ---------------------------------------------------------------------
SDK_SRC="crates/op-web-sdk/src"
sdk_ref_hits="$(grep -RIn 'op_editor_ui' "${SDK_SRC}" 2>/dev/null || true)"
if [ -n "${sdk_ref_hits}" ]; then
sdk_illegal_imports="$(printf '%s\n' "${sdk_ref_hits}" \
| grep 'widgets' \
| grep -vE '^crates/op-web-sdk/src/viewer_host(\.rs|/[^/]+\.rs):' \
|| true)"
if [ -n "${sdk_illegal_imports}" ]; then
fail_lines+=("SDK-F4: op_editor_ui::widgets reference outside viewer_host.rs in op-web-sdk:" "${sdk_illegal_imports}")
fi
fi
# ---------------------------------------------------------------------
# Reverse R1: each expected widget impl file exists AND
# carries a real `impl Widget for X` line that is NOT a Rust