354 Commits

Author SHA1 Message Date
alonso.torres
1aafee93fd ♻️ Share duplicated CJK layout helpers across both axes
Several CJK passes had a copy per writing direction. Merge them without
changing their results:

- allocate_bounded_expansion and bounded_expansion divide justify and
  distribute slack for both axes. Each axis still collects its own gaps;
  the horizontal one checks no-break controls over the source range and
  the vertical one at cell edges, as before.
- PolicyBoundaries holds the break and emergency rules at each grapheme
  of a paragraph. The horizontal overlong-word and atomic-overflow passes
  and the vertical pairwise column planner read it. relax_overlong_words
  moves next to retain_atomic_overflow and shares annotation_ranges.
- TextLayoutPolicy::pair_spacing_em gives the compression and script gap
  that both axes apply with their own caps.
- cjk_chars keeps the Han, kana, hangul and punctuation ranges. Ranges
  that differ on purpose stay separate there, with a note on why.
- OverhangClaims tracks the ruby overhang an earlier reading takes.
- horizontal_zhuyin_glyphs places Zhuyin for position data and painting.
- paint_horizontal_annotations paints warichu, emphasis and ruby; the
  emoji overlay keeps its place between the marks and the ruby.

AI-assisted-by: claude-opus-5-5
2026-11-12 00:49:45 +01:00
alonso.torres
5b8db9b72d ♻️ Make the text layout policy one language decision
TextLayoutPolicy had nine flat variants that mixed language, Chinese
region and Korean wrap. It is now Plain, Japanese, Chinese(ChineseRegion)
or Korean(KoreanWrap), and the layout code asks it named questions
(uses_jlreq, plans_breaks, keeps_overlong_units, reopens_overlong_words,
bounded_justify, needs_joiner, normalizes_western_spaces,
uses_clreq_positions, ...) instead of comparing variants.

Paragraph parses its language once, when the language or the Korean wrap
changes, and copies the policy to its spans. Span code no longer builds a
policy from the tag with a dummy Korean wrap, and the emphasis and
underline positions take the policy instead of a Chinese flag.

A LanguageTag parser now serves both the layout policy and the CJK font
choice. It also accepts '_' as a separator, as the font choice did, so
a tag such as "ja_JP" now selects the Japanese policy.

The korean-wrap byte decodes through a new exported RawKoreanWrap enum
(Syllable=0, SpacesOnly=1, KeepWords=2) with the same byte values.

AI-assisted-by: claude-opus-5-5
2026-11-12 00:49:45 +01:00
alonso.torres
53979fc7d3 ♻️ Rename CJK text modules and helpers in render-wasm
The CJK layout code kept names from when it only handled Japanese.
Chinese regional presets, Pinyin, Zhuyin and Korean now share it, so
rename the modules and helpers to say what they do:

- kinsoku.rs becomes line_breaks.rs: the layout-text rewrite with
  OffsetMap and the inserted break markers. The kinsoku predicates move
  to jlreq.rs (was japanese.rs); JapaneseClass becomes JlreqClass.
- is_japanese_text_char becomes is_cjk_text_char.
- text_japanese.rs splits into text_attrs.rs (span attribute enums),
  text_ruby.rs (ruby pieces and overhang), text_warichu.rs and
  text_allocation.rs (capped_equal_allocations, shared by both axes).
- wasm/text/japanese_layout.rs becomes layout_attrs.rs.
- RubySide becomes AnnotationSide; RawTextSpan.ruby_side becomes
  ruby_flags, since the byte packs the side and the Zhuyin bit.
- horizontal_aki_sheds becomes the horizontal_pair_spacing dispatcher
  with a jlreq_aki_sheds branch; the plan field sheds is pair_spacing.
- The policy_* helpers become pairwise_* and the Japanese ones jlreq_*.
- The oikomi policy parameter is adjustment and minimum_oikomi_extent is
  min_squeezed_extent.

Exported functions, Raw* enums and byte layouts stay unchanged.

AI-assisted-by: claude-opus-5-5
2026-11-12 00:49:45 +01:00
alonso.torres
ceac049e18 🐛 Paint CJK underlines with the color of their own span
Skia keys a line's style runs by UTF-8 byte offset, while line indices
are UTF-16. Decoration segments searched those runs with UTF-16 offsets,
so with CJK text every underline or strike on a line took the paint of
the first run, and a decorated span wrapped onto a new line lost its bar.

Look up each segment's run by the span's UTF-8 style anchor, falling
back to the line's first run for a span continued from an earlier line.

AI-assisted-by: claude-opus-5-5
2026-11-12 00:48:37 +01:00
alonso.torres
f7869c9cbe 🐛 Keep distributed and justified lines inside the text box
SkParagraph moves each line right by half the letter-spacing of its first
character. That centres span tracking, but the spacing added to single
characters (distribution and justification gaps, squeezing, compression
and ruby room) must only open the gap after them. A distributed label of
two characters started half a gap in and overflowed the box by as much:
"国家" in a 200px box drew 国 92px from the left edge.

Builders of horizontal text record how far each adjusted character's
letter-spacing departs from its span tracking, and `LayoutParagraph` takes
back half of that on each line, next to its baseline shifts: painting,
glyph and placeholder rects, line metrics, hit testing and text paths.
Span tracking keeps its half spacing before the line.

AI-assisted-by: claude-opus-5-5
2026-11-12 00:48:37 +01:00
alonso.torres
376f3b096a ✨ Add distributed text alignment and preserve fractional line spacing
Add distributed alignment to text styles, sidebar controls, CSS export,
and the Plugin API. Spread characters across horizontal and vertical
lines, including the final line, while preserving graphemes and annotations.

Keep fractional line heights consistent across painting, hit testing,
selection, and text paths to prevent baseline drift between lines.

Add tests for distribution, nonbreaking text, and fractional line geometry.

AI-assisted-by: gpt-6
2026-11-12 00:48:37 +01:00
alonso.torres
471803a446 🐛 Keep indivisible CJK units together in narrow text boxes
Retain legal line starts at the original wrap width before allowing an
indivisible punctuation or annotation unit to overflow together. Share the
layout plan and alignment offset across painting, editing and exports.

Cover mixed styles, nonbreaking controls, graphemes, annotations, alignment,
strokes and resize. All 666 renderer tests and both WASM builds pass.

AI-assisted-by: GPT-6
2026-11-12 00:48:20 +01:00
alonso.torres
9f0082c2a4 ✨ Add Chinese emphasis and underline positions
Carry optional span positions through the schema, WASM header, editor,
Plugin API and CSS output. Expose Chinese controls with language defaults
and independent overrides for horizontal and vertical text.

Reserve automatic clearance per side and stack underlines, ruby and
emphasis. Preserve reading groups during decoration edits and retain
canonical position values in DOM editor style data.

Add protocol, geometry, editing and API tests, including the prior Japanese
mixed-font gutter behavior. Verify both WASM targets and live exports.

AI-assisted-by: GPT-6
2026-11-12 00:48:10 +01:00
alonso.torres
c66859714d ✨ Add Chinese regional and Korean word layout
Add named Chinese punctuation presets and a Korean keep-words mode for
horizontal and vertical layout. Preserve the saved spaces-only mode and
keep source offsets stable through safe emergency wrapping.

Apply bounded CJK spacing and justification, expose the new choices in the
sidebar, and document preset bounds and remaining release checks.

Verify renderer, frontend, common and exporter tests, both WASM builds,
and sample save/reload plus PNG, PDF and SVG exports. Existing CI failures
remain documented in the layout notes.

AI-assisted-by: GPT-6
2026-11-12 00:48:10 +01:00
alonso.torres
8dbdb00fde ✨ Add a CJK text layout proof of concept
Choose paragraph layout rules from an optional language tag. Pass language
to horizontal and vertical shaping, add basic Chinese punctuation rules,
and add experimental Korean spaces-only wrapping.

Keep text boundaries grapheme-safe and preserve paragraph profiles across
editor splits and merges. Add reproducible font fixtures for localized
glyphs and regression tests for the shared WASM protocol and layout paths.

Validation: renderer, common JVM/JS, frontend and exporter tests pass.
Both WASM targets build. Full CI still reports baseline lint and format
errors and one backend multipart-upload test failure.

AI-assisted-by: GPT-6
2026-11-12 00:47:52 +01:00
alonso.torres
991a93aa98 ✨ Render Japanese text layouts in WASM
Lay out horizontal and vertical Japanese text with kinsoku, ruby,
emphasis, warichu, tate-chu-yoko and proportional font features.
Carry the layout through glyph paths, SVG export and text selection.

Update the Rust and ClojureScript binary formats together, including
the editor bridge and headless exporter. Resolve CJK fallback fonts
and add renderer tests and font fixtures.
2026-11-12 00:47:09 +01:00
Elena Torró
7f0b62b994
🐛 Keep drop shadows out from under translucent shapes (#12904)
* 🐛 Keep drop shadows out from under translucent shapes at every zoom

Cut the shape out of its drop shadow, as CSS box-shadow does, and stop
large shadows from being cut off at high zoom.

AI-assisted-by: claude-opus-5-5

* 🐛 Keep drop shadows out from under translucent shapes in SVG and PDF

Match the canvas: clip the shape out of its drop shadow on export.

AI-assisted-by: claude-opus-5-5
2026-11-10 15:05:08 +01:00
Miguel de Benito Delgado
01191051c7
🐛 Replace stale atlas pixels when zooming (#12884)
Copy finished tiles with BlendMode::Src so transparent pixels replace what the atlas slot held before
2026-11-09 14:28:13 +01:00
Alejandro Alonso
94836cb8c8 Merge remote-tracking branch 'origin/staging' into develop 2026-10-30 13:22:09 +01:00
Elena Torró
b48d163f5f
⚡ Fix slow drop shadow spread in CPU exports (#12718)
* ⚡ Fix slow drop shadow spread in CPU exports

* 🐛 Fix text opacity in board drop shadows on CPU exports
2026-10-29 18:27:19 +01:00
Alejandro Alonso
deb5771248
✨ Use WASM for SVG markup in code inspect and copy (#12567) (#12608)
When render-wasm is active, generate SVG for the Code inspector and
Copy as SVG from the WASM export path instead of classic SVG codegen.
Add multi-root render_shapes_svg so multi-select stays one document.

Closes #12567
2026-10-27 12:05:30 +01:00
Belén Albeza
2045fc4900
🐛 Keep text image fills in place when pasting (#12552)
A text image fill was always laid over the whole text box, so a pasted
image span showed another part of the picture in a box of another size.

Text image fills now honour the image transform, as shape fills do: the
image covers the transform rect instead of the whole box. Paste gives
each pasted image span a transform that keeps the image at the same
pixel offset from the span, like the gradients.

The SVG export emits one linked image per span transform, rather than
one per image id, and the style panel keeps the transform it reads back.

AI-assisted-by: claude-opus-5-5
2026-10-23 13:00:53 +02:00
Alejandro Alonso
e27c65c77a
✨ Run WASM asset exports locally in the browser (#12515)
When render-wasm is active, shape exports (single file, ZIP, and Export
boards multi-page PDF) run on the shared worker WASM module so the UI
stays responsive. A progressive PDF session (begin/add-page/finish)
updates progress per page; cancel settles on the client. The server
exporter path remains for the SVG renderer.
2026-10-23 11:02:23 +02:00
Belén Albeza
0505eb3b1d
🐛 Fix pasted gradient fills in text editor v3 (#12516)
* 🐛 Keep text gradients in place when pasting at the caret

Text gradients are stored as fractions of the whole text box. Paste
copied those fractions unchanged, so in a box of another size the
handles and colours moved away from the pasted text.

Copy from the v3 editor now records where each span with a gradient or
image fill starts in its text box, and the box size, in the clipboard
payload. A new WASM export, text_editor_get_range_origin, gives the
start of any text range.

Paste at the caret finds where those spans start in the target and
rewrites their gradients so the handles keep the same pixel offset from
the span. Auto-sized texts use the box size they get when the editor
closes.

AI-assisted-by: claude-opus-5-5

* 🐛 Rebuild text gradient shaders when the text box resizes

Text gradient and image shaders are built into the cached Skia
paragraphs against the text box size. A resize with no content change
reused those paragraphs, so the colours stayed matched to the old box
until a reload.

The text now remembers the box size its shaders were built for and lays
itself out again when that size changes and a span has a gradient or
image fill. Text with only solid fills keeps its cache.

AI-assisted-by: claude-opus-5-5

* 🐛 Keep text gradients in place when pasting on the canvas

A text selection pasted on the canvas becomes a new text whose box
differs from the source, so its gradients moved like they did for caret
paste.

After adding the new text, lay it out, find where its gradient spans
start and move their gradients to keep the same pixel offset from the
span, in the same undo step. The range origin export now takes a shape
id, so it works on texts that are not being edited.

AI-assisted-by: claude-opus-5-5

* 🐛 Keep text gradients in place when pasting copied texts at the caret

Text shapes copied whole, rather than as an editor selection, reached the
caret paste without any geometry, so their gradients still moved away from
the pasted text.

A shape copy now records where the gradient and image spans of each
selected text start, and the box size, by shape id. Paste joins them in
the order of the joined paragraphs, with no origin for texts copied
without one.

Copied texts each have their own box, so every span origin now carries
its own box size instead of one box for the whole payload.

AI-assisted-by: claude-opus-5-5
2026-10-22 13:39:26 +02:00
Andrey Antukh
fcf9c27635 Merge remote-tracking branch 'origin/staging' into develop 2026-10-21 10:14:55 +02:00
Elena Torró
a1606c42ff
🐛 Export bitmaps over the GPU surface limit through the exporter (#12455) 2026-10-20 15:37:07 +02:00
Belén Albeza
ef68304cb8
🐛 Draw masked group inner shadows in PDF and multi-element export (#12468)
* 🐛 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.
2026-10-20 15:04:54 +02:00
Belén Albeza
60a94c65cc
🐛 Fix missing letters when copying text from exported PDFs (#12441)
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
2026-10-20 09:32:53 +02:00
Andrey Antukh
fffc3ee222 Merge remote-tracking branch 'origin/staging' into develop 2026-10-19 22:18:27 +02:00
Elena Torró
0801674af2
🐛 Ignore export scale in render-wasm PDF export (#12447) 2026-10-19 18:21:02 +02:00
Alejandro Alonso
29cd50621d
🐛 Exclude inner shadows from WebGL export bounds (#12427)
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.
2026-10-19 17:23:26 +02:00
Alejandro Alonso
655300e4d1
🐛 Include board drop shadow in multi-element raster export (#12440)
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
2026-10-19 15:29:40 +02:00
Elena Torró
c401151541
🐛 Fix mask group drop shadow in PDF export (#12362) 2026-10-19 12:07:51 +02:00
Andrey Antukh
472ba8dde5 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:49:04 +02:00
Elena Torró
ccdfaffbef
🐛 Fix stroke to path for strokes with different widths per side (#12356) 2026-10-16 12:36:23 +02:00
Andrey Antukh
473a59b134 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 09:01:12 +02:00
Elena Torró
29ea6e49b6
🐛 Step over whole emojis in the text editor (#12202)
Caret moves, deletes and caret rects use grapheme boundaries.
Closes #12188
2026-10-08 13:42:25 +02:00
Elena Torró
b3a62eeb4b
🐛 Paint layout children in canvas z-order in vector exports (#12171) 2026-10-08 09:07:33 +02:00
Alejandro Alonso
cc5f2f57ad
⬆️ Upgrade skia-safe to 0.153.3 with wasm SIMD (#12148)
Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt
fonts, path stroke expansion, and gradients to the new API.
2026-10-07 12:08:33 +02:00
Andrey Antukh
746184caf9 Merge remote-tracking branch 'origin/staging' into develop 2026-10-06 15:58:01 +02:00
Elena Torró
70dfe90571
🐛 Clear doc atlas when canvas background color changes (#12112) 2026-10-06 10:34:47 +02:00
Elena Torró
09ffcb1fbc
⚡ Reduce render-wasm GPU memory and move cost on large pages (#11980) 2026-10-02 10:24:05 +02:00
Belén Albeza
60679bbbcd
🐛 Revert RTL auto-width text growing from its right edge (#12051)
Revert #11775 (commit 142f3d9de8). We are putting this fix on hold
until we settle the UX for RTL auto-width text.

Relates to #11523
2026-10-01 18:29:07 +02:00
Alejandro Alonso
cba51cfb7e
🐛 Keep adjacent tile compose origins abutted (#11968)
Round the view offset once and place tiles at
origin + k * TILE_SIZE so half-pixel pans do not
open a 1px background seam between neighbors.
2026-09-29 12:38:04 +02:00
Elena Torró
c44484a1e5
🐛 Fix SVG filter shadows and inherited group fills in render-wasm: (#11925)
* 🐛 Fix shadows derived from imported SVG filters

* 🐛 Fix inherited group fills in exports and group drop shadows

* 🔧 Run exporter tests on render-wasm changes
2026-09-28 07:29:25 +02:00
Elena Torró
4943285e63
🐛 Fix inner and outer rect strokes ignoring corner radius (#11926) 2026-09-25 14:12:01 +02:00
Elena Torró
63baf86152
🐛 Fix groups, masks and booleans drop shadow cases (#11911)
* 🐛 Fix blocky and black drop shadows on masked groups

* 🐛 Fix drop shadow spread on paths, bools and circles
2026-09-25 11:50:19 +02:00
Alejandro Alonso
e3e75725d4
✨ Export WASM SVG background blur via foreignObject (#11765)
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.
2026-09-24 15:29:25 +02:00
Belén Albeza
a34b4011cf
🎉 Render different stroke styles per side (#11883)
* 🎉 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
2026-09-24 13:20:33 +02:00
Alejandro Alonso
ad7b93beb7
✨ Export masked groups to WASM SVG as alpha masks (#11652)
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
2026-09-24 11:25:34 +02:00
Belén Albeza
142f3d9de8
🐛 Fix RTL auto-width text growing away from its right edge (#11775)
This makes RTL texts in auto-width to grow towards their left side in
the text editor v3.

AI-assisted-by: claude-opus-5
2026-09-23 13:02:17 +02:00
Miguel de Benito Delgado
f0880cf3f9
🐛 Crop cached drop shadows to their filter bounds (#11801)
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
2026-09-22 11:09:45 +02:00
Belén Albeza
0581c35452
🐛 Fix drop shadows in masks (#11754)
* 🐛 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
2026-09-18 13:04:11 +02:00
Elena Torró
cc9c60507d
🐛 Do not apply stroke style to caps (#11744) 2026-09-17 09:39:39 +02:00
Alejandro Alonso
20547f658e
🐛 Preserve tile coverage when packing atlas slots (#11749)
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
2026-09-16 16:40:33 +02:00