Text image shaders are baked into Skia paragraphs at layout time.
touch_shapes_with_image only scanned shape.fills/strokes, so text
spans were never invalidated after store_image. Detect text content
image fills and force a layout rebuild before touching tiles.
Closes#12563
* 🐛 Fix render_to_pdf call in the ToUnicode test
The test passed a scale argument that render_to_pdf does not take, so
the render-wasm test build failed to compile.
* 🐛 Draw masked group inner shadows in PDF and multi-element export
The CPU renderer behind PDF and multi-element raster export drew a
masked group's drop shadow but never its inner shadow, so the masked
content came out flat. Wrap the masked content in a layer that draws
the inner shadows over it, as the GPU single export does.
* 🐛 Export every visible inner shadow of a masked group
The workspace skips shadows too small to see at the current zoom, to
render faster. Export has no such need, so draw every visible inner
shadow instead of filtering by scale.
WASM find_guide_at only checked distance on the guide axis, so board-bound
guides stayed interactive outside their board. Respect frame_range in
GuidePool hit testing so interaction matches the drawn solid segment and
the SVG hit area.
Closes#12322
Skia maps ligatures and other substituted glyphs to U+0000 in the PDF
ToUnicode maps. Rebuild those maps from the laid-out paragraphs and
append them as an incremental update.
Closes#12393
Inner shadows were joined into extrect via the drop-shadow filter, so a
Y offset grew the PNG/SVG export surface. Only drop
shadows expand bounds, matching classic export. Closes#12386.
A modified shape is a cached copy, and edits through get_mut did not
reset it. After Enter the preview stops sending modifiers while the size
holds, so the canvas kept painting the old text.
AI-assisted-by: claude-opus-5-5
Headless raster/PDF used the board selrect so overflowing children stayed
cropped, but that also clipped the board's own drop shadow. Use
export_root_bounds: own strokes/shadows/blur without joining children.
Closes#12388
Bump the pnpm pin to 12.14.0 in the devenv and the production images
and stamp the same version into every package.json, realigning the
drift between the devenv (12.9.1) and the rest (12.6.0).
Bump Node to v24.22.0, the newest patch of the Krypton LTS line, in
.nvmrc, the devenv and image Dockerfiles and the plugins-deploy
workflows.
The pnpm arch checksums are those of the GitHub release tarballs, not
of the npm package. Lockfile changes are metadata only.
AI-assisted-by: deepseek-v4.1-flash
With a range selected, Left and Right were moving one character from
the focus instead of collapsing to the selection start or end. Match
standard text-field behavior and keep Shift+arrows extending.
Closes#12207
Click caret placement only moved the selection and never refreshed
current styles, so the Design tab kept showing the previous fill and
typography. Place the caret through place_caret_from_position so
StylesChanged is emitted like keyboard navigation.
Closes#12195
Bump the devenv and image Dockerfiles to pnpm 12.6.0
(latest stable; 12.8.0 is still on the next tag) with
fresh SHA256 pins, and opencode to 1.18.33 plus
opencode2 to 2.0.18 with fresh checksums.
Stamp all 35 package.json files via
scripts/sync-pnpm-version and refresh the 11 lockfiles;
diffs are metadata-only, with no dependency re-resolution.
Drop the last corepack calls from the media-processor
build script and its generated image setup: pnpm now
ships as a system binary and self-heals version drift.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix shadows derived from imported SVG filters
* 🐛 Fix inherited group fills in exports and group drop shadows
* 🔧 Run exporter tests on render-wasm changes
Drop the separate :wasm-export flag and wasm-export/v1 feature. Single
export, clipboard PNG, plugins, and batch :is-wasm now key off
render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM
and always keeps a worker pool ready.
Protect shared setup, compilation, artifact copy, and target cleanup with
one flock lock per checkout. Route watch builds and frontend cleanup
through the protected scripts.
Document the lock contract and normalize the frontend and exporter
build:wasm commands.
Closes#11901
AI-assisted-by: Space Bunny Free
Emit an XHTML foreignObject with CSS backdrop-filter for visible
background blur. The inner div uses clip-path:path(...) for the GPU
fill+stroke silhouette (glyph outlines for text). The FO sits outside
opacity and layer-blur filters. Stacked on the masks SVG export work.
* 🎉 Render different stroke styles per side
* 🔧 Stabilise per-side stroke snapshots against clip id churn
SVG clip ids come from a counter that lives for the whole test
process, so a snapshot holding one depends on how many clips other
tests emitted first. The masked-group export tests that landed on
develop shifted that count and broke two per-side snapshots, whose
only diff was the id.
Route the remaining per-side snapshots through with_stable_clip_ids,
which renumbers ids in order of appearance. No snapshot in the suite
now carries a generated id.
AI-assisted-by: claude-opus-5
Paint the mask subtree into a native <mask mask-type="alpha"> and wrap
content under mask="url(#…)", matching GPU DstIn (soft/group masks).
Drop silhouettes use the same alpha mask; empty-fill SVG paths inherit
group fills.
Closes#11378
A masked group now closes its focus scope on the mask pass, pops the
nested stacks on the content pass only, and draws its exit strokes on
the content pass. The focus depth also resets when each tile's walk
starts.
Closes#11805
AI-assisted-by: claude-opus-5
Snapshot only the scaled shadow bounds instead of the full reusable
filter surface. This avoids retaining viewport-sized textures for each
small shadow while keeping cached pixels valid when the surface is reused.
Use the bounded snapshot for both shape and frame shadow caches. Add
regression tests for rounding, scaling, surface limits, and snapshot reuse.
AI-assisted-by: gpt-6
* ⬆️ Update devenv dependencies
Update Node.js, OpenCode, clj-kondo, Babashka, Pixi, GitHub CLI, uv,
and Serena to their current stable releases.
AI-assisted-by: gpt-5.6-sol
* ⬆️ Update devenv to Java 27
Use Zulu JDK 27 in the development image for compatibility testing.
Update the official checksums for both supported architectures.
AI-assisted-by: gpt-5.6-sol
* 🐳 Replace MinIO with RustFS in devenv
Run RustFS as the development S3 service and wait for its health check.
Install a pinned AWS CLI with checksums and use it to create the bucket
idempotently from each backend entry point.
Keep the old MinIO volume untouched and use a new RustFS volume.
AI-assisted-by: gpt-5.6-sol
* 🐳 Replace MailCatcher with persistent Mailpit
Run Mailpit as the devenv SMTP sink while preserving mailer:1025 and the
localhost:1080 UI.
Store its SQLite inbox in a named volume and wait for the readiness
endpoint before starting runtime containers. Bind the web UI to loopback so
development emails stay local.
AI-assisted-by: gpt-5.6-sol
* ⬆️ Update Node.js to 24.21.0
Align the host NVM version with the Node.js version used by devenv.
AI-assisted-by: gpt-5.6-sol
* ⬆️ Update devenv to PostgreSQL 18.6
Run PostgreSQL 18 with its versioned volume layout and a TCP readiness
check that ignores the temporary initialization server.
Install the matching client, create penpot_nexus, and preserve the old
PostgreSQL 16 volume for rollback or logical migration.
AI-assisted-by: gpt-5.6-sol
* 🐳 Expose RustFS ports in devenv
Publish the RustFS S3 API and management console on localhost port 9000
and 9001.
Keep both bindings on loopback so object storage is not exposed to the local
network.
AI-assisted-by: gpt-5.6-sol
* 🐳 Install standalone pnpm in devenv
Install pnpm 12.5.0 from architecture-specific release archives and
verify their published checksums.
Remove the Corepack setup while allowing pnpm to honor the project
packageManager pins.
AI-assisted-by: gpt-5.6-sol
* 🔥 Remove corepack, use system pnpm everywhere
Corepack is gone from Node 25+, so every `corepack enable` call
fails. pnpm now ships as a system binary (devenv, CI runners and
Docker images install it directly) and auto-downloads the version
pinned in `packageManager` on mismatch.
Scripts, workflows and Dockerfiles call `pnpm` straight away; the
three deploy workflows use a single `pnpm/setup@v2` step; and the
new `scripts/sync-pnpm-version` stamps all 35 `packageManager`
fields from the system pnpm, replacing the `corepack use` sweep.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix exporter watch missing render-wasm build step
The exporter watch compiled CLJS requiring the generated
src/app/wasm/shared.js, which only render-wasm/build export
produces. Without it shadow-cljs failed with a cryptic missing
./shared.js dependency. Run build:wasm before watching, as
the frontend watch:app and exporter scripts/build already do.
AI-assisted-by: muse-spark-1.3-contributor
* 🔧 Add opencode V2 support and adapt plugins
Register the penpot tools for both opencode V1 (server())
and V2 (setup() with JSON Schema inputs) from a single
dependency-free plugin file, sharing the psql and
paren-repair runners between both paths.
Install the opencode2 binary side-by-side with V1 in the
devenv image and document the dual registration in the
paren-repair and psql memories.
AI-assisted-by: muse-spark-1.3-contributor
* ⬆️ Update pnpm and opencode
* 🐛 Fix shadows on a masked group in the WASM renderer
A masked group renders in two passes: its content, then the mask shape
composited with DstIn so everything outside the mask silhouette is
erased. Both happen inside one save_layer, and the group's drop shadow
was composited into that same layer before the mask pass — so the mask
erased it. A drop shadow lives mostly outside the silhouette, so it
disappeared entirely.
Inner shadows never drew at all: render_fill_inner_shadows needs fill
geometry to paint into, and a group has none.
Both now ride on an image filter set on the masked-group layer, which
Skia evaluates after the mask is composited, so the shadow comes from
the real masked pixels rather than the group's own, empty geometry.
The effects compose in the order the SVG renderer uses for a group:
drop shadows, then the source, then inner shadows, with the layer blur
over all of it.
That layer is opened on a canvas carrying no transform, so the filter
is built in device units. Shadow::scale_to_device does that rather
than scale_content, because radius_to_sigma is affine: scaling the
radius applies its constant term once at device scale, while a filter
built in document space has the term scaled by the canvas matrix. The
two would blur differently by 0.5 · (scale - 1) sigma, visible as a
masked group's own shadow being narrower than the same shadow on its
parent. The masked-group layer blur had the same flaw.
Three paths are suppressed for masked groups so nothing is drawn twice:
the silhouette composite, the nested_shadows inheritance that would
reach text descendants, and the fill inner-shadow pass.
Every save and restore around that layer is keyed on the shape alone.
Enter and exit run on different walker passes, and a pan or zoom in
between changes fast mode, so deriving them from render state could
leave the canvas clip stack unbalanced.
Refs #11697
AI-assisted-by: claude-opus-5
* 🐛 Fix a container's drop shadow over a masked group in WASM
A container builds its drop shadow by drawing each descendant as a
black silhouette and blurring the result. The walk descends only
through children that can be flattened, and a masked group never can,
so it stopped there and asked the group to draw its own geometry. A
group has none, so nothing was drawn and the shadow layer stayed
blank: no shadow at all for a group, board or frame holding a masked
group. This is what the file attached to the issue reproduces.
render_drop_black_shadow now draws the masked silhouette for such a
group — content children flat black, DstIn the mask, and only then the
offset, blur and spread. Masking after the blur would trim the shadow
along the wrong edge.
The walk recurses, so it narrows the clip the way the main walker
does: content a clipping container hides must not widen the shadow.
The clip rule now lives in one place, shared with the walker, and a
test pins the two against each other. The shadow layer is sized to the
silhouette plus the shadow's reach rather than falling back to the
clip, so a wide blur is not cut at the tile edge.
Spread keeps the renderer's existing behaviour: the silhouette goes
through the same get_drop_shadow_filter every other shadow uses, so a
masked group gains no ordering of its own.
Closes#11697
AI-assisted-by: claude-opus-5
* ⚡ Capture canvas snapshot only when the renderer is idle
* ⚡ Skip descendant silhouettes for frames with nested drop shadows
* 🔧 Add a profiling build mode for render-wasm
Packed atlas compose inset Linear samples after blitting the full
drawable into the slot, which dropped edge texels and opened
multi-pixel gaps on text that crossed tile seams (#11696), especially
under HiDPI packing. Write the drawable into the inset content rect
and clamp-pad the 1px frame so Linear compose keeps coverage without
bleeding into the next cell.
Closes#11696
Clipped frames (show-content=false) were applying clipPath to the drop
silhouette. Spread/offset fills were outset correctly but then truncated
to the true selrect, so spread rings vanished on export. Match the GPU:
paint drop silhouettes outside the content clip; keep clipping on the
real content pass only.
Closes#11653
SkSVGDevice drops save_layer composition for text strokes, so emit
center strokes (with <g opacity> when alpha), inner strokes via glyph
clipPath, and outer strokes via inverse-glyph luminance mask.
Closes#11386
* 🐛 Fix stroke to path dropping caps and markers
Caps and markers (round ends, arrows, square/diamond/circle markers) are
painted separately by `handle_stroke_caps` after the stroke itself, so
`stroke_to_path` — which only outlines the shape path — dropped them.
Converting a stroke to a path lost every end decoration except the
Round/Round and Square/Square pairs Skia draws natively, and even those
were missing because the outline paint kept the default butt cap.
Cap geometry now lives in `shapes/stroke_paths.rs` as plain path
builders, shared by the canvas renderer and by `stroke_to_path`, which
unions the caps into the outline for open paths and honors
`to_skia_linecap`. The SVG export no longer overlays the caps a second
time on top of the expanded outline, which would double the alpha of
translucent strokes; the clip silhouette for image strokes gets them
from the outline too.
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
AA clip on the Current→atlas blit softens shared tile edges so
the canvas background shows through as 1px lines at the 512px
grid when tiles are composed with SrcOver.
Closes#11638