mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b278f8fa |
No files matched your search
@@ -25,7 +25,6 @@
|
||||
[app.common.uuid :as uuid]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.wasm.enums]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[shadow.esm :refer [dynamic-import]]))
|
||||
|
||||
@@ -191,22 +190,6 @@
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
false)))))
|
||||
|
||||
(defn store-image-url!
|
||||
"Registers the public URL an image was loaded from. The SVG export emits
|
||||
linked `<image href>` from these, and falls back to Skia base64 when missing,
|
||||
so this should run for every media id the scene references (including
|
||||
already-cached images).
|
||||
|
||||
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
|
||||
[image-id url]
|
||||
(when (and (some? url) (not (str/blank? url)))
|
||||
(let [bytes (js/Buffer.from url "utf-8")
|
||||
ptr (mem/alloc (.-byteLength bytes))
|
||||
quart (uuid/get-u32 image-id)]
|
||||
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
|
||||
|
||||
(defn store-image!
|
||||
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
|
||||
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
|
||||
|
||||
@@ -369,18 +369,13 @@
|
||||
"Fetches and stores every image the scene references (shape, stroke and
|
||||
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
|
||||
the image store is not reset per request, so already-held images are skipped
|
||||
and repeated exports of a file reuse them.
|
||||
|
||||
Always registers a public media URL for each id so SVG export can emit linked
|
||||
`<image href>` even when the encoded bytes were already cached."
|
||||
and repeated exports of a file reuse them."
|
||||
[scene params]
|
||||
(let [all-ids (images/scene-image-ids scene)
|
||||
new-ids (remove wasm/image-cached? all-ids)]
|
||||
(l/dbg :hint "wasm render: provisioning images"
|
||||
:total (count all-ids)
|
||||
:cached (- (count all-ids) (count new-ids)))
|
||||
(doseq [image-id all-ids]
|
||||
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
|
||||
(->> new-ids
|
||||
(map (fn [image-id]
|
||||
(->> (fetch-file-media-bytes image-id params)
|
||||
|
||||
@@ -703,9 +703,9 @@
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(defn set-wasm-modifiers
|
||||
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel
|
||||
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis
|
||||
subtree-ids-by-id selection-rect-cache]
|
||||
:or {ignore-constraints false ignore-snap-pixel false}
|
||||
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil}
|
||||
:as params}]
|
||||
(let [modif-tree (without-nil-ids modif-tree)]
|
||||
(ptk/reify ::set-wasm-modifiers
|
||||
@@ -756,7 +756,7 @@
|
||||
root-modifiers
|
||||
|
||||
:else
|
||||
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
|
||||
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)]
|
||||
(if (seq propagated) propagated root-modifiers)))]
|
||||
(when wasm-ready?
|
||||
(wasm.api/set-modifiers modifiers))
|
||||
@@ -831,10 +831,8 @@
|
||||
;; primaries and descendants would snap back to their
|
||||
;; pre-drag positions on drop.
|
||||
;;
|
||||
;; Skipped when `snap-pixel?` is on: WASM applies
|
||||
;; per-shape pixel correction (different scale/translate
|
||||
;; per descendant) which we can't replicate cheaply on
|
||||
;; the CLJS side.
|
||||
;; Only without `snap-pixel?`: the delta that lands
|
||||
;; the shape on the pixel grid is known to WASM alone.
|
||||
(reduce
|
||||
(fn [acc [id data]]
|
||||
(let [t (:transform data)
|
||||
@@ -864,7 +862,7 @@
|
||||
geometry-entries))
|
||||
|
||||
:else
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)))
|
||||
|
||||
ignore-tree
|
||||
(calculate-ignore-tree-wasm transforms objects)
|
||||
|
||||
@@ -592,12 +592,14 @@
|
||||
(rx/merge
|
||||
(->> angle-stream
|
||||
(rx/sample mconst/rotation-sample-time)
|
||||
(rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)))
|
||||
(rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)
|
||||
:ignore-snap-pixel true))
|
||||
(rx/take-until stopper))
|
||||
(->> angle-stream
|
||||
(rx/take-until stopper)
|
||||
(rx/last)
|
||||
(rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)))))
|
||||
(rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)
|
||||
:ignore-snap-pixel true))))
|
||||
|
||||
(rx/of (finish-transform)))
|
||||
|
||||
@@ -638,7 +640,9 @@
|
||||
modif-tree
|
||||
(dwm/build-modif-tree ids objects get-modifier)]
|
||||
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree
|
||||
:ignore-touched (:ignore-touched options)
|
||||
:ignore-snap-pixel true)))
|
||||
|
||||
(let [page-id (or (:page-id options)
|
||||
(:current-page-id state))
|
||||
|
||||
@@ -880,25 +880,6 @@
|
||||
(h/call wasm/internal-module "_store_image")
|
||||
true)))))
|
||||
|
||||
(defn- store-image-url!
|
||||
"Registers the public URL an image was loaded from so SVG export can emit a
|
||||
linked `<image href>` instead of a Skia base64 embed."
|
||||
[image-id url]
|
||||
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
encoder (js/TextEncoder.)
|
||||
encoded (.encode encoder url)
|
||||
size (.-byteLength encoded)
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)]
|
||||
(.set heap encoded offset)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3))
|
||||
true)))
|
||||
|
||||
(defn- store-image-texture
|
||||
"Creates a WebGL texture from a decoded image and passes the texture ID to
|
||||
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
|
||||
@@ -941,7 +922,6 @@
|
||||
so Skia rasterizes them."
|
||||
[shape-id image-id thumbnail?]
|
||||
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
|
||||
(store-image-url! image-id url)
|
||||
{:key url
|
||||
:thumbnail? thumbnail?
|
||||
:callback
|
||||
@@ -979,8 +959,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
;; Always register the URL (SVG export needs it even when bytes are cached).
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?)))))
|
||||
|
||||
@@ -1015,7 +993,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))))
|
||||
@@ -1044,7 +1021,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
image-ids))))
|
||||
@@ -2122,13 +2098,34 @@
|
||||
|
||||
(h/call wasm/internal-module "_set_structure_modifiers"))))
|
||||
|
||||
;; Axes the pixel grid rounds, as `propagate_modifiers` expects them.
|
||||
(def ^:private pixel-precision
|
||||
{:disabled 0
|
||||
:both 1
|
||||
:only-x 2
|
||||
:only-y 3})
|
||||
|
||||
(defn- pixel-precision-mode
|
||||
"Encodes the pixel grid snapping for the renderer. `snap-ignore-axis`
|
||||
names the axis to leave alone (`:x`, `:y` or nil)."
|
||||
[snap-pixel? snap-ignore-axis]
|
||||
(pixel-precision
|
||||
(cond
|
||||
(not snap-pixel?) :disabled
|
||||
(= :x snap-ignore-axis) :only-y
|
||||
(= :y snap-ignore-axis) :only-x
|
||||
:else :both)))
|
||||
|
||||
(defn propagate-modifiers
|
||||
"Propagates geometry modifiers through the WASM shape tree.
|
||||
|
||||
Rounds the resulting geometry to the pixel grid when `snap-pixel?` is set,
|
||||
skipping the axis named by `snap-ignore-axis` (`:x`, `:y` or nil).
|
||||
|
||||
Always returns a vector. When the context is not ready (lost / mid-reload)
|
||||
or `entries` is empty, returns `[]` so callers never receive `nil` (which
|
||||
would trip `set-modifiers`' vector assert)."
|
||||
[entries pixel-precision]
|
||||
[entries snap-pixel? snap-ignore-axis]
|
||||
(if-not (and (initialized?) (not ^boolean (empty? entries)))
|
||||
[]
|
||||
(let [heapf32 (mem/get-heap-f32)
|
||||
@@ -2146,7 +2143,8 @@
|
||||
offset
|
||||
entries)
|
||||
|
||||
(let [offset (-> (h/call wasm/internal-module "_propagate_modifiers" pixel-precision)
|
||||
(let [precision (pixel-precision-mode snap-pixel? snap-ignore-axis)
|
||||
offset (-> (h/call wasm/internal-module "_propagate_modifiers" precision)
|
||||
(mem/->offset-32))
|
||||
length (aget heapu32 offset)
|
||||
max-offset (+ offset 1 (* length MODIFIER-U32-SIZE))
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
This effectively tells the caller \"apply exactly the transform that
|
||||
was requested\", which is what the real WASM engine does for simple
|
||||
moves / resizes without constraints."
|
||||
[entries _pixel-precision]
|
||||
[entries _snap-pixel? _snap-ignore-axis]
|
||||
(track! :propagate-modifiers)
|
||||
(when (d/not-empty? entries)
|
||||
(into []
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
the real implementations."
|
||||
[]
|
||||
(set! wasm.api/propagate-modifiers
|
||||
(fn [entries _pixel-precision]
|
||||
(fn [entries _snap-pixel? _snap-ignore-axis]
|
||||
(swap! captured-geometry-entries into entries)
|
||||
(into []
|
||||
(map (fn [[id data]] [id (:transform data)]))
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.logic.wasm-pixel-snap-test
|
||||
"Covers which pixel-grid snapping options reach the WASM renderer.
|
||||
|
||||
The rounding happens in Rust, so these tests assert on the arguments
|
||||
crossing the bridge: which axis an axis-locked drag leaves alone, and
|
||||
that rotation does not snap."
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.test-helpers.compositions :as ctho]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]))
|
||||
|
||||
(def ^:private captured-snap-options
|
||||
"One entry per `wasm.api/propagate-modifiers` call during a test."
|
||||
(atom []))
|
||||
|
||||
(defn- install-capturing-spy!
|
||||
"Records the snap options of every propagation. Must run after
|
||||
`thw/setup-wasm-mocks!` so teardown restores the real implementation."
|
||||
[]
|
||||
(set! wasm.api/propagate-modifiers
|
||||
(fn [entries snap-pixel? snap-ignore-axis]
|
||||
(swap! captured-snap-options conj
|
||||
{:snap-pixel? snap-pixel? :snap-ignore-axis snap-ignore-axis})
|
||||
(into []
|
||||
(map (fn [[id data]] [id (:transform data)]))
|
||||
entries))))
|
||||
|
||||
(defn- enable-pixel-grid
|
||||
[]
|
||||
(fn [state]
|
||||
(update state :workspace-layout conj :snap-pixel-grid)))
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
(cthi/reset-idmap!)
|
||||
(reset! captured-snap-options [])
|
||||
(thw/setup-wasm-mocks!)
|
||||
(install-capturing-spy!))
|
||||
:after (fn []
|
||||
(thw/teardown-wasm-mocks!))})
|
||||
|
||||
(t/deftest axis-locked-move-tells-the-renderer-which-axis-to-ignore
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect1)
|
||||
modif-tree (dwm/create-modif-tree [(:id rect)]
|
||||
(ctm/move-modifiers (gpt/point 5.2 0)))
|
||||
events [(enable-pixel-grid)
|
||||
(dwm/apply-wasm-modifiers modif-tree :snap-ignore-axis :y)]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [_new-state]
|
||||
(t/is (= [{:snap-pixel? true :snap-ignore-axis :y}]
|
||||
@captured-snap-options)))))))
|
||||
|
||||
(t/deftest move-without-axis-lock-snaps-both-axes
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect1)
|
||||
modif-tree (dwm/create-modif-tree [(:id rect)]
|
||||
(ctm/move-modifiers (gpt/point 5.2 3.7)))
|
||||
events [(enable-pixel-grid)
|
||||
(dwm/apply-wasm-modifiers modif-tree)]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [_new-state]
|
||||
(t/is (= [{:snap-pixel? true :snap-ignore-axis nil}]
|
||||
@captured-snap-options)))))))
|
||||
|
||||
(t/deftest rotation-does-not-snap-to-the-pixel-grid
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect1)
|
||||
events [(enable-pixel-grid)
|
||||
(dwt/increase-rotation #{(:id rect)} 15)]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [_new-state]
|
||||
(t/is (= [{:snap-pixel? false :snap-ignore-axis nil}]
|
||||
@captured-snap-options)))))))
|
||||
@@ -45,6 +45,7 @@
|
||||
[frontend-tests.logic.sidebar-transform-coalescing-test]
|
||||
[frontend-tests.logic.update-position-test]
|
||||
[frontend-tests.logic.wasm-modifiers-nil-id-test]
|
||||
[frontend-tests.logic.wasm-pixel-snap-test]
|
||||
[frontend-tests.main-errors-test]
|
||||
[frontend-tests.plugins.comments-test]
|
||||
[frontend-tests.plugins.context-shapes-test]
|
||||
@@ -152,6 +153,7 @@
|
||||
'frontend-tests.logic.sidebar-transform-coalescing-test
|
||||
'frontend-tests.logic.update-position-test
|
||||
'frontend-tests.logic.wasm-modifiers-nil-id-test
|
||||
'frontend-tests.logic.wasm-pixel-snap-test
|
||||
'frontend-tests.plugins.comments-test
|
||||
'frontend-tests.plugins.context-shapes-test
|
||||
'frontend-tests.plugins.file-test
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#
|
||||
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
|
||||
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
|
||||
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
|
||||
#
|
||||
# When a test produced a pending change there will be a `*.snap.new` next to the
|
||||
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
|
||||
@@ -28,14 +27,10 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
|
||||
OUT="$OUT_DIR/index.html"
|
||||
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
|
||||
FONT_DIR="$OUT_DIR/fonts"
|
||||
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
|
||||
IMAGE_DIR="$OUT_DIR/images"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
mkdir -p "$FONT_DIR"
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
cp "$FONT_SRC" "$FONT_DIR/"
|
||||
cp "$IMAGE_SRC" "$IMAGE_DIR/"
|
||||
|
||||
# Prints the SVG body of a snapshot file: everything after the second `---`
|
||||
# line (the YAML front matter insta writes).
|
||||
|
||||
@@ -82,9 +82,6 @@ pub struct ImageStore {
|
||||
tick: Cell<u64>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
/// Source URL registered when the image was fetched (SVG export references
|
||||
/// this in linked `<image>` elements).
|
||||
source_urls: HashMap<Uuid, String>,
|
||||
}
|
||||
|
||||
/// Creates a Skia image from an existing WebGL texture.
|
||||
@@ -230,7 +227,6 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: Some(Box::new(context.clone())),
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +239,6 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: None,
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,14 +476,4 @@ impl ImageStore {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
|
||||
if !url.is_empty() {
|
||||
self.source_urls.insert(id, url);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
|
||||
self.source_urls.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
|
||||
|
||||
/// Accumulates the SVG document body while drawing.
|
||||
pub(crate) struct SvgLayerCanvas {
|
||||
scale: f32,
|
||||
pub(super) scale: f32,
|
||||
page_rect: skia::Rect,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
@@ -97,28 +97,6 @@ impl SvgLayerCanvas {
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
|
||||
pub(super) fn push_raw(&mut self, markup: &str) {
|
||||
self.flush();
|
||||
self.out.push_str(markup);
|
||||
}
|
||||
|
||||
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
|
||||
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
|
||||
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
|
||||
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
|
||||
ctm = ctm * shape.centered_transform();
|
||||
format!(
|
||||
"matrix({} {} {} {} {} {})",
|
||||
ctm.scale_x(),
|
||||
ctm.skew_y(),
|
||||
ctm.skew_x(),
|
||||
ctm.scale_y(),
|
||||
ctm.translate_x(),
|
||||
ctm.translate_y()
|
||||
)
|
||||
}
|
||||
|
||||
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
|
||||
///
|
||||
/// A mask can be a group too. Since a group has no geometry of its own, we
|
||||
|
||||
@@ -5,7 +5,7 @@ use skia_safe as skia;
|
||||
use crate::globals::TestRenderResourcesGuard;
|
||||
use crate::render::{FontStore, RenderResources};
|
||||
use crate::shapes::{
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Path, Rect, Segment,
|
||||
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
|
||||
Type,
|
||||
};
|
||||
@@ -18,10 +18,6 @@ use super::render_tree_to_svg;
|
||||
/// Font URL referenced in exported SVG `@font-face` rules.
|
||||
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
|
||||
|
||||
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
|
||||
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
|
||||
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
|
||||
|
||||
fn register_test_font_urls(fonts: &mut FontStore) {
|
||||
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
||||
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
|
||||
@@ -32,31 +28,6 @@ pub(super) fn uid(n: u32) -> Uuid {
|
||||
uuid_from_u32_quartet(0, 0, 0, n)
|
||||
}
|
||||
|
||||
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
|
||||
pub(super) fn add_image_rect(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
keep_aspect_ratio: bool,
|
||||
opacity: u8,
|
||||
) {
|
||||
add_rect_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Image(ImageFill::new(
|
||||
image_id,
|
||||
opacity,
|
||||
200,
|
||||
100,
|
||||
keep_aspect_ratio,
|
||||
))],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a solid-filled rectangle to the pool.
|
||||
pub(super) fn add_solid_rect(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -97,101 +68,15 @@ pub(super) fn add_frame(
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
color: skia::Color,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Solid(SolidColor(color))],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn add_frame_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
fills: Vec<Fill>,
|
||||
clip: bool,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Frame(Frame::default()));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(fills);
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
|
||||
shape.set_clip(clip);
|
||||
}
|
||||
|
||||
/// Frame whose background is a linked image fill.
|
||||
pub(super) fn add_image_frame(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![test_image_fill(image_id)],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn triangle_segments(closed: bool) -> Vec<Segment> {
|
||||
let mut segments = vec![
|
||||
Segment::MoveTo((10.0, 90.0)),
|
||||
Segment::LineTo((50.0, 10.0)),
|
||||
Segment::LineTo((90.0, 90.0)),
|
||||
];
|
||||
if closed {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
fn add_path_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
segments: Vec<Segment>,
|
||||
fills: Vec<Fill>,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Path(Path::new(segments)));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(fills);
|
||||
}
|
||||
|
||||
fn test_image_fill(image_id: Uuid) -> Fill {
|
||||
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
|
||||
}
|
||||
|
||||
/// Triangle path (open or closed) with a linked image fill.
|
||||
pub(super) fn add_image_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
closed: bool,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_path_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(0.0, 0.0, 100.0, 100.0),
|
||||
triangle_segments(closed),
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds an empty (unmasked) group.
|
||||
pub(super) fn add_group(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -364,39 +249,9 @@ fn stroke_with_style(
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Text with a linked image fill (register URL via `render_with`).
|
||||
pub(super) fn add_image_text(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
text: &str,
|
||||
font_size: f32,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_text_with_fills(
|
||||
pool,
|
||||
id,
|
||||
bounds,
|
||||
text,
|
||||
font_size,
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
|
||||
render_with(pool, root, |_resources| {})
|
||||
}
|
||||
|
||||
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
|
||||
/// before export.
|
||||
pub(super) fn render_with(
|
||||
pool: &ShapesPool,
|
||||
root: Uuid,
|
||||
setup: impl FnOnce(&mut RenderResources),
|
||||
) -> String {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
register_test_font_urls(&mut resources.fonts);
|
||||
setup(&mut resources);
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
|
||||
String::from_utf8(bytes).expect("utf8 svg")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#2563eb"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="100" fill="url(#g)"/>
|
||||
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
|
||||
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
|
||||
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 610 B |
@@ -5,7 +5,6 @@ use crate::shapes::{Shape, Stroke};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::images::emit_fills;
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
@@ -30,9 +29,14 @@ pub(super) fn render_frame(
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
}
|
||||
|
||||
// Frame background (frame space), with linked `<image>` for image fills.
|
||||
// Frame background (frame space).
|
||||
if !element.fills.is_empty() {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Children (absolute coords).
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
use crate::error::Result;
|
||||
use crate::render::shape_renderer::ShapeRenderer;
|
||||
use crate::render::vector::VectorRenderer;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
/// Emits fills bottom -> top for SVG export.
|
||||
///
|
||||
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
|
||||
/// source URL become native linked `<image>` elements (see `store_image_url`);
|
||||
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
|
||||
pub(super) fn emit_fills(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
shape: &Shape,
|
||||
fills: &[Fill],
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
if fills.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// fills[0] is the topmost layer; draw bottom → top.
|
||||
for fill in fills.iter().rev() {
|
||||
match fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_fill(builder, shared, shape, image_fill, tree)?;
|
||||
}
|
||||
fill => {
|
||||
let matrix = shape.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits a linked SVG `<image>` clipped to the shape geometry.
|
||||
///
|
||||
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
|
||||
/// a native `<image href="...">` instead so the export stays linked to the
|
||||
/// registered media URL (see `store_image_url`).
|
||||
fn emit_image_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
tree: ShapesPoolRef,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("imgclip");
|
||||
builder.push_clip_path(&clip_id, shape, tree);
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
|
||||
pub(super) fn emit_linked_image_element(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
href: &str,
|
||||
clip_id: &str,
|
||||
) {
|
||||
let selrect = shape.selrect();
|
||||
let opacity = image_fill.opacity() as f32 / 255.0;
|
||||
let preserve = if image_fill.keep_aspect_ratio() {
|
||||
"xMidYMid slice"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
let transform = builder.page_shape_matrix_attr(shape);
|
||||
|
||||
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#" opacity="{opacity}""#)
|
||||
};
|
||||
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
builder.push_raw(&format!(
|
||||
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
|
||||
selrect.left(),
|
||||
selrect.top(),
|
||||
selrect.width(),
|
||||
selrect.height(),
|
||||
));
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
pub(super) fn xml_escape_attr(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
@@ -8,8 +8,7 @@ use crate::shapes::{Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::vector::VectorRenderer;
|
||||
use super::vector::{render_leaf_content, VectorRenderer};
|
||||
use super::RenderResources;
|
||||
|
||||
/// Collects the registered font aliases used by every text span in the subtree
|
||||
@@ -126,7 +125,6 @@ pub(crate) fn render_tree_to_svg(
|
||||
mod document;
|
||||
mod frames;
|
||||
mod groups;
|
||||
mod images;
|
||||
mod text;
|
||||
|
||||
use document::SvgLayerCanvas;
|
||||
@@ -135,7 +133,6 @@ use groups::render_group;
|
||||
use text::render_text_fill;
|
||||
|
||||
use document::effect_attrs;
|
||||
use images::emit_fills;
|
||||
|
||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||
fn render_body(
|
||||
@@ -175,7 +172,7 @@ fn render_tree(
|
||||
| Type::Path(_)
|
||||
| Type::Bool(_)
|
||||
| Type::Text(_)
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +180,6 @@ fn render_leaf(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(element);
|
||||
@@ -193,26 +189,14 @@ fn render_leaf(
|
||||
|
||||
{
|
||||
if matches!(element.shape_type, Type::Text(_)) {
|
||||
render_text_fill(builder, shared, element)?;
|
||||
render_text_fill(builder, element)?;
|
||||
} else {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
if !element.has_fills() {
|
||||
for stroke in &visible_strokes {
|
||||
renderer.draw_stroke_inner_shadows(element, stroke)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
render_leaf_content(&mut renderer, element)?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="100" height="80"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="200" height="120"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
assertion_line: 306
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
|
||||
HOLA
|
||||
</text>
|
||||
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
|
||||
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</svg>
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::fixtures::*;
|
||||
|
||||
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
|
||||
use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind};
|
||||
use crate::state::ShapesPool;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
@@ -962,229 +962,3 @@ fn exports_solid_text_with_font_face() {
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_text() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_text(
|
||||
&mut pool,
|
||||
id,
|
||||
(0.0, 0.0, 560.0, 240.0),
|
||||
"HOLA",
|
||||
200.0,
|
||||
image_id,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"text image fill must emit a linked <image>: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"text image fill must be clipped to glyph silhouette: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("<clipPath ") && svg.contains("<text"),
|
||||
"clipPath must contain text glyphs: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("data:image"),
|
||||
"must not base64-embed the image: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_as_linked_image() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
image_id,
|
||||
true,
|
||||
255,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image"),
|
||||
"image fill must emit an <image> element: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(TEST_IMAGE_URL),
|
||||
"image href must use the registered URL: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"keep-aspect image fill must slice: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to shape geometry: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("data:image"),
|
||||
"must not base64-embed the image: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_mixed_solid_and_image_fills_in_order() {
|
||||
// Image under a translucent solid; stretch (keep-aspect off); partial image
|
||||
// opacity; shape not at the page origin (page translate in CTM).
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_rect_with_fills(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(100.0, 50.0, 508.0, 178.0),
|
||||
vec![
|
||||
// fills[0] topmost — solid blue @ 50%
|
||||
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
|
||||
// fills[1] underneath — linked image, stretch, ~50% opacity
|
||||
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
|
||||
],
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
let image_pos = svg.find("<image");
|
||||
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
|
||||
assert!(image_pos.is_some(), "missing image fill: {svg}");
|
||||
assert!(blue_pos.is_some(), "missing top solid fill: {svg}");
|
||||
assert!(
|
||||
image_pos.unwrap() < blue_pos.unwrap(),
|
||||
"image (bottom) must appear before solid (top): {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"none\""),
|
||||
"mixed image fill should stretch when keep-aspect is off: {svg}"
|
||||
);
|
||||
// 128/255 → ~0.50196 as f32 (not a rounded "0.5").
|
||||
assert!(
|
||||
svg.contains("opacity=\"0.5019608\""),
|
||||
"image fill opacity must be emitted: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_closed_path() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_path(&mut pool, id, Uuid::nil(), true, image_id);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"closed path must emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to the path: {svg}"
|
||||
);
|
||||
// Clip geometry should be a path (triangle), not a plain rect.
|
||||
assert!(
|
||||
svg.contains("<path") || svg.contains(" d=\""),
|
||||
"closed-path clip must use path geometry: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"keep-aspect image fill on path: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_open_path() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_path(&mut pool, id, Uuid::nil(), false, image_id);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"open path must still emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to the open path geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_frame() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_frame(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
image_id,
|
||||
false,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"frame background must emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"frame image fill must be clipped to the frame: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"frame image fill should keep aspect: {svg}"
|
||||
);
|
||||
// No nested board clip when clip_content is off.
|
||||
assert!(
|
||||
!svg.contains("id=\"clip0\""),
|
||||
"unclipped frame should not wrap children in a board clip: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
@@ -1,122 +1,19 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::render::text;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::shapes::Shape;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use super::images::{emit_linked_image_element, xml_escape_attr};
|
||||
use crate::render::RenderResources;
|
||||
use crate::render::text;
|
||||
|
||||
/// Emits a text shape's fills for SVG export.
|
||||
/// Emits a text shape's fill as native `<text>` elements.
|
||||
///
|
||||
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
|
||||
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
|
||||
pub(super) fn render_text_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
element: &Shape,
|
||||
) -> Result<()> {
|
||||
let text_content = element.get_text_content();
|
||||
let text_content = text_content.new_bounds(element.selrect());
|
||||
let max_layers = text_content.max_fill_layers();
|
||||
if max_layers == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
|
||||
/// silently drops. Text strokes are handled separately in a later PR.
|
||||
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
|
||||
let matrix = element.centered_transform();
|
||||
|
||||
for layer in 0..max_layers {
|
||||
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
|
||||
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
|
||||
|
||||
for image_fill in &linked {
|
||||
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
|
||||
}
|
||||
|
||||
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
|
||||
let mut paragraph_builders = if skip_ids.is_empty() {
|
||||
text_content.paragraph_builder_group_for_fill_layer(layer)
|
||||
} else {
|
||||
text_content
|
||||
.paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
|
||||
};
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn linked_image_fills_at_layer<'a>(
|
||||
text_content: &'a crate::shapes::TextContent,
|
||||
layer: usize,
|
||||
shared: &RenderResources,
|
||||
) -> Vec<&'a ImageFill> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for paragraph in text_content.paragraphs() {
|
||||
for span in paragraph.children() {
|
||||
if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
|
||||
if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn layer_has_skia_fills(
|
||||
text_content: &crate::shapes::TextContent,
|
||||
layer: usize,
|
||||
skip_ids: &HashSet<Uuid>,
|
||||
) -> bool {
|
||||
text_content.paragraphs().iter().any(|paragraph| {
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.any(|span| match span.fills_from_bottom(layer) {
|
||||
Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer.
|
||||
fn emit_text_image_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
layer: usize,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("txtimgclip");
|
||||
let text_content = shape.get_text_content().new_bounds(shape.selrect());
|
||||
let mut paragraph_builders =
|
||||
text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
|
||||
|
||||
let canvas = builder.new_fragment();
|
||||
{
|
||||
let cv: &skia_safe::Canvas = &canvas;
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
|
||||
cv.restore();
|
||||
}
|
||||
builder.finish_clip_path_fragment(&clip_id, canvas);
|
||||
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
text::paint_text_fill(canvas, element);
|
||||
canvas.restore();
|
||||
Ok(())
|
||||
}
|
||||
@@ -530,13 +530,22 @@ fn render_text_on_canvas(
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
|
||||
pub fn paint_text_paragraphs(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||
) {
|
||||
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
|
||||
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
|
||||
/// `SkSVGDevice` would drop.
|
||||
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
|
||||
let text_content = shape.get_text_content();
|
||||
let text_content = text_content.new_bounds(shape.selrect());
|
||||
let max_layers = text_content.max_fill_layers();
|
||||
if max_layers == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
|
||||
// attributes (merged shaders are dropped). Bottom layer first.
|
||||
for layer in 0..max_layers {
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
|
||||
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out and paints paragraph builders without any layer management.
|
||||
|
||||
@@ -12,8 +12,8 @@ use common::GetBounds;
|
||||
use crate::error::Result;
|
||||
use crate::shapes;
|
||||
use crate::shapes::{
|
||||
ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry,
|
||||
TransformEntrySource, Type,
|
||||
ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape,
|
||||
TransformEntry, TransformEntrySource, Type,
|
||||
};
|
||||
use crate::state::{ShapesPoolRef, State};
|
||||
use crate::uuid::Uuid;
|
||||
@@ -139,36 +139,84 @@ fn calculate_bool_bounds(
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
|
||||
let tr = bounds.transform_matrix().unwrap_or_default();
|
||||
let tr_inv = tr.invert().unwrap_or_default();
|
||||
/// Which parts of the geometry a pixel-grid correction rounds: only the ones
|
||||
/// the transform changes, so a move keeps its dimensions and a resize keeps
|
||||
/// its anchored corner.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
struct SnapGeometry {
|
||||
x: bool,
|
||||
y: bool,
|
||||
width: bool,
|
||||
height: bool,
|
||||
}
|
||||
|
||||
let x = bounds.min_x().round();
|
||||
let y = bounds.min_y().round();
|
||||
impl SnapGeometry {
|
||||
/// Flags the properties that differ between the two bounds. The axis mask
|
||||
/// in `precision` applies to the position only.
|
||||
fn new(before: &Bounds, after: &Bounds, precision: PixelPrecision) -> Self {
|
||||
SnapGeometry {
|
||||
x: precision.rounds_x() && !is_close_to(before.min_x(), after.min_x()),
|
||||
y: precision.rounds_y() && !is_close_to(before.min_y(), after.min_y()),
|
||||
width: !is_close_to(before.width(), after.width()),
|
||||
height: !is_close_to(before.height(), after.height()),
|
||||
}
|
||||
}
|
||||
|
||||
let width = bounds.width();
|
||||
let height = bounds.height();
|
||||
fn resized(&self) -> bool {
|
||||
self.width || self.height
|
||||
}
|
||||
|
||||
let target_width = bounds.width().round();
|
||||
let target_height = bounds.height().round();
|
||||
fn any(&self) -> bool {
|
||||
self.x || self.y || self.resized()
|
||||
}
|
||||
}
|
||||
|
||||
let scale_width = if width > 0.1 {
|
||||
f32::max(0.01, target_width / width)
|
||||
/// Rounds a transform so the parts of the shape the gesture changed land on
|
||||
/// the pixel grid, leaving everything else exactly where it is.
|
||||
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds, snap: SnapGeometry) {
|
||||
// Target corner, taken before the size correction: that correction scales
|
||||
// about the bounds center, and the translation below undoes the corner
|
||||
// displacement it causes. An unsnapped axis targets its own value.
|
||||
let x = if snap.x {
|
||||
bounds.min_x().round()
|
||||
} else {
|
||||
1.0
|
||||
bounds.min_x()
|
||||
};
|
||||
let scale_height = if height > 0.1 {
|
||||
f32::max(0.01, target_height / height)
|
||||
let y = if snap.y {
|
||||
bounds.min_y().round()
|
||||
} else {
|
||||
1.0
|
||||
bounds.min_y()
|
||||
};
|
||||
|
||||
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
|
||||
let mut round_transform = Matrix::scale((scale_width, scale_height));
|
||||
round_transform.post_concat(&tr);
|
||||
round_transform.pre_concat(&tr_inv);
|
||||
transform.post_concat(&round_transform);
|
||||
bounds.transform_mut(&round_transform);
|
||||
if snap.resized() {
|
||||
let tr = bounds.transform_matrix().unwrap_or_default();
|
||||
let tr_inv = tr.invert().unwrap_or_default();
|
||||
|
||||
let width = bounds.width();
|
||||
let height = bounds.height();
|
||||
|
||||
// A rounded dimension is never smaller than one pixel.
|
||||
let target_width = f32::max(1.0, width.round());
|
||||
let target_height = f32::max(1.0, height.round());
|
||||
|
||||
let scale_width = if snap.width && width > 0.1 {
|
||||
f32::max(0.01, target_width / width)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let scale_height = if snap.height && height > 0.1 {
|
||||
f32::max(0.01, target_height / height)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
|
||||
let mut round_transform = Matrix::scale((scale_width, scale_height));
|
||||
round_transform.post_concat(&tr);
|
||||
round_transform.pre_concat(&tr_inv);
|
||||
transform.post_concat(&round_transform);
|
||||
bounds.transform_mut(&round_transform);
|
||||
}
|
||||
}
|
||||
|
||||
let dx = x - bounds.min_x();
|
||||
@@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn propagate_transform(
|
||||
entry: TransformEntry,
|
||||
pixel_precision: bool,
|
||||
pixel_precision: PixelPrecision,
|
||||
state: &State,
|
||||
entries: &mut VecDeque<Modifier>,
|
||||
bounds: &mut HashMap<Uuid, Bounds>,
|
||||
@@ -286,8 +334,11 @@ fn propagate_transform(
|
||||
}
|
||||
}
|
||||
|
||||
if pixel_precision {
|
||||
set_pixel_precision(&mut transform, &mut shape_bounds_after);
|
||||
if pixel_precision.enabled() {
|
||||
let snap = SnapGeometry::new(&shape_bounds_before, &shape_bounds_after, pixel_precision);
|
||||
if snap.any() {
|
||||
set_pixel_precision(&mut transform, &mut shape_bounds_after, snap);
|
||||
}
|
||||
}
|
||||
|
||||
if entry.propagate {
|
||||
@@ -417,10 +468,15 @@ fn reflow_shape(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Propagates a set of transforms through the shape tree, returning one
|
||||
/// transform per affected shape.
|
||||
///
|
||||
/// The transforms are relative to the committed geometry, so callers clear
|
||||
/// any transform modifier of their own before propagating.
|
||||
pub fn propagate_modifiers(
|
||||
state: &State,
|
||||
modifiers: &[TransformEntry],
|
||||
pixel_precision: bool,
|
||||
pixel_precision: PixelPrecision,
|
||||
) -> Result<Vec<TransformEntry>> {
|
||||
let mut entries: VecDeque<_> = modifiers
|
||||
.iter()
|
||||
@@ -567,6 +623,249 @@ mod tests {
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_precision_move_keeps_size() {
|
||||
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
|
||||
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 3.7)));
|
||||
let mut transform = Matrix::translate((5.2, 3.7));
|
||||
|
||||
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
|
||||
set_pixel_precision(&mut transform, &mut bounds_after, snap);
|
||||
|
||||
assert!(is_close_to(bounds_after.width(), 100.5));
|
||||
assert!(is_close_to(bounds_after.height(), 50.3));
|
||||
assert!(is_close_to(bounds_after.min_x(), 16.0));
|
||||
assert!(is_close_to(bounds_after.min_y(), 24.0));
|
||||
assert!(math::is_move_only_matrix(&transform));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_precision_resize_rounds_size() {
|
||||
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
|
||||
let mut bounds_after = bounds.transform(&Matrix::scale((1.1, 1.1)));
|
||||
let mut transform = Matrix::scale((1.1, 1.1));
|
||||
|
||||
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
|
||||
set_pixel_precision(&mut transform, &mut bounds_after, snap);
|
||||
|
||||
assert!(is_close_to(
|
||||
bounds_after.width(),
|
||||
bounds_after.width().round()
|
||||
));
|
||||
assert!(is_close_to(
|
||||
bounds_after.height(),
|
||||
bounds_after.height().round()
|
||||
));
|
||||
assert!(is_close_to(
|
||||
bounds_after.min_x(),
|
||||
bounds_after.min_x().round()
|
||||
));
|
||||
assert!(is_close_to(
|
||||
bounds_after.min_y(),
|
||||
bounds_after.min_y().round()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_propagate_pixel_precision_move_only_rounds_position() {
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut state = State::new();
|
||||
state.shapes.initialize(10);
|
||||
{
|
||||
let shape = state.shapes.add_shape(shape_id);
|
||||
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
|
||||
}
|
||||
|
||||
let entry = TransformEntry::from_input(shape_id, Matrix::translate((5.2, 3.7)));
|
||||
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
|
||||
|
||||
let transform = result
|
||||
.iter()
|
||||
.find(|entry| entry.id == shape_id)
|
||||
.map(|entry| entry.transform)
|
||||
.unwrap();
|
||||
|
||||
let shape = state.shapes.get(&shape_id).unwrap();
|
||||
let bounds = shape.bounds().transform(&transform);
|
||||
|
||||
assert!(is_close_to(bounds.width(), 100.5));
|
||||
assert!(is_close_to(bounds.height(), 50.3));
|
||||
assert!(is_close_to(bounds.min_x(), 16.0));
|
||||
assert!(is_close_to(bounds.min_y(), 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_propagate_pixel_precision_resize_keeps_anchored_corner() {
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut state = State::new();
|
||||
state.shapes.initialize(10);
|
||||
{
|
||||
let shape = state.shapes.add_shape(shape_id);
|
||||
shape.set_selrect(10.4, 20.6, 110.4, 70.6);
|
||||
}
|
||||
|
||||
// Drag the bottom-right corner in small steps: the top-left corner
|
||||
// stays put on every step.
|
||||
for step in 1..40 {
|
||||
let delta = step as f32 * 0.05;
|
||||
let mut resize = Matrix::scale(((100.0 + delta) / 100.0, (50.0 + delta) / 50.0));
|
||||
resize.post_translate(Point::new(10.4, 20.6));
|
||||
resize.pre_translate(Point::new(-10.4, -20.6));
|
||||
|
||||
let entry = TransformEntry::from_input(shape_id, resize);
|
||||
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
|
||||
|
||||
let transform = result
|
||||
.iter()
|
||||
.find(|entry| entry.id == shape_id)
|
||||
.map(|entry| entry.transform)
|
||||
.unwrap();
|
||||
|
||||
let shape = state.shapes.get(&shape_id).unwrap();
|
||||
let bounds = shape.bounds().transform(&transform);
|
||||
|
||||
assert!(
|
||||
is_close_to(bounds.min_x(), 10.4) && is_close_to(bounds.min_y(), 20.6),
|
||||
"corner moved to ({}, {}) at delta {}",
|
||||
bounds.min_x(),
|
||||
bounds.min_y(),
|
||||
delta
|
||||
);
|
||||
assert!(is_close_to(bounds.width(), bounds.width().round()));
|
||||
assert!(is_close_to(bounds.height(), bounds.height().round()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_precision_only_x_leaves_y_untouched() {
|
||||
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
|
||||
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 0.0)));
|
||||
let mut transform = Matrix::translate((5.2, 0.0));
|
||||
|
||||
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyX);
|
||||
set_pixel_precision(&mut transform, &mut bounds_after, snap);
|
||||
|
||||
assert!(is_close_to(bounds_after.min_x(), 16.0));
|
||||
assert!(is_close_to(bounds_after.min_y(), 20.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_precision_only_y_leaves_x_untouched() {
|
||||
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
|
||||
let mut bounds_after = bounds.transform(&Matrix::translate((0.0, 3.7)));
|
||||
let mut transform = Matrix::translate((0.0, 3.7));
|
||||
|
||||
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyY);
|
||||
set_pixel_precision(&mut transform, &mut bounds_after, snap);
|
||||
|
||||
assert!(is_close_to(bounds_after.min_x(), 10.4));
|
||||
assert!(is_close_to(bounds_after.min_y(), 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_precision_resize_never_rounds_below_one_pixel() {
|
||||
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.0, 20.0, 0.4, 0.3));
|
||||
let mut bounds_after = bounds.transform(&Matrix::scale((1.5, 1.5)));
|
||||
let mut transform = Matrix::scale((1.5, 1.5));
|
||||
|
||||
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
|
||||
set_pixel_precision(&mut transform, &mut bounds_after, snap);
|
||||
|
||||
assert!(is_close_to(bounds_after.width(), 1.0));
|
||||
assert!(is_close_to(bounds_after.height(), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_propagate_pixel_precision_snaps_every_frame_of_a_gesture() {
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut state = State::new();
|
||||
state.shapes.initialize(10);
|
||||
{
|
||||
let shape = state.shapes.add_shape(shape_id);
|
||||
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
|
||||
}
|
||||
|
||||
// One frame of a drag, as the entry point runs it: clear the
|
||||
// modifiers, propagate the delta accumulated since the gesture
|
||||
// started, then push the result back as the active modifier, which is
|
||||
// what the renderer draws.
|
||||
let frame = |state: &mut State, delta: f32| {
|
||||
state.shapes.clear_transform_modifiers();
|
||||
|
||||
let entry = TransformEntry::from_input(shape_id, Matrix::translate((delta, delta)));
|
||||
let result = propagate_modifiers(state, &[entry], PixelPrecision::Both).unwrap();
|
||||
let transform = result
|
||||
.iter()
|
||||
.find(|entry| entry.id == shape_id)
|
||||
.map(|entry| entry.transform)
|
||||
.unwrap();
|
||||
|
||||
let bounds = state
|
||||
.shapes
|
||||
.get_raw(&shape_id)
|
||||
.unwrap()
|
||||
.bounds()
|
||||
.transform(&transform);
|
||||
|
||||
state.set_modifiers(HashMap::from([(shape_id, transform)]));
|
||||
bounds
|
||||
};
|
||||
|
||||
// Every frame lands on the pixel grid and keeps the size.
|
||||
for step in 1..40 {
|
||||
let bounds = frame(&mut state, step as f32 * 0.35);
|
||||
|
||||
assert!(
|
||||
is_close_to(bounds.min_x(), bounds.min_x().round())
|
||||
&& is_close_to(bounds.min_y(), bounds.min_y().round()),
|
||||
"shape landed off the pixel grid at ({}, {}) on frame {}",
|
||||
bounds.min_x(),
|
||||
bounds.min_y(),
|
||||
step
|
||||
);
|
||||
assert!(is_close_to(bounds.width(), 100.5));
|
||||
assert!(is_close_to(bounds.height(), 50.3));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_propagate_pixel_precision_resize_only_rounds_the_changed_dimension() {
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut state = State::new();
|
||||
state.shapes.initialize(10);
|
||||
{
|
||||
let shape = state.shapes.add_shape(shape_id);
|
||||
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
|
||||
}
|
||||
|
||||
// Drag the right edge: the width lands on the grid, the height and
|
||||
// the top-left corner stay put.
|
||||
let mut resize = Matrix::scale((103.3 / 100.5, 1.0));
|
||||
resize.post_translate(Point::new(10.4, 20.6));
|
||||
resize.pre_translate(Point::new(-10.4, -20.6));
|
||||
|
||||
let entry = TransformEntry::from_input(shape_id, resize);
|
||||
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
|
||||
|
||||
let transform = result
|
||||
.iter()
|
||||
.find(|entry| entry.id == shape_id)
|
||||
.map(|entry| entry.transform)
|
||||
.unwrap();
|
||||
|
||||
let bounds = state
|
||||
.shapes
|
||||
.get_raw(&shape_id)
|
||||
.unwrap()
|
||||
.bounds()
|
||||
.transform(&transform);
|
||||
|
||||
assert!(is_close_to(bounds.width(), 103.0));
|
||||
assert!(is_close_to(bounds.height(), 50.3));
|
||||
assert!(is_close_to(bounds.min_x(), 10.4));
|
||||
assert!(is_close_to(bounds.min_y(), 20.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_bounds() {
|
||||
let parent_id = Uuid::new_v4();
|
||||
|
||||
+14
-114
@@ -796,13 +796,13 @@ impl TextContent {
|
||||
&self,
|
||||
use_shadow: Option<bool>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(use_shadow, false, None, None, None, None)
|
||||
self.paragraph_builders(use_shadow, false, None, None)
|
||||
}
|
||||
|
||||
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
|
||||
/// Used as a clip mask for inner stroke rendering.
|
||||
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(None, true, None, None, None, None)
|
||||
self.paragraph_builders(None, true, None, None)
|
||||
}
|
||||
|
||||
/// Maximum number of stacked fills across every span in this text block.
|
||||
@@ -821,42 +821,7 @@ impl TextContent {
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
|
||||
}
|
||||
|
||||
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
|
||||
/// this layer is an image in `skip_image_ids` get transparent paint (those
|
||||
/// fills are re-emitted as linked SVG `<image>` elements).
|
||||
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
skip_image_ids: &HashSet<Uuid>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
Some(layer_from_bottom),
|
||||
None,
|
||||
Some(skip_image_ids),
|
||||
)
|
||||
}
|
||||
|
||||
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
|
||||
/// the given image — used as an SVG `<clipPath>` for linked image fills.
|
||||
pub fn paragraph_builder_group_opaque_for_image_layer(
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
image_id: Uuid,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some((layer_from_bottom, image_id)),
|
||||
None,
|
||||
)
|
||||
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
|
||||
}
|
||||
|
||||
fn paragraph_builders(
|
||||
@@ -865,8 +830,6 @@ impl TextContent {
|
||||
opaque: bool,
|
||||
align_override: Option<skia::textlayout::TextAlign>,
|
||||
fill_layer: Option<usize>,
|
||||
opaque_image_layer: Option<(usize, Uuid)>,
|
||||
skip_image_ids: Option<&HashSet<Uuid>>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
let fonts = get_font_collection();
|
||||
let fallback_fonts = get_fallback_fonts();
|
||||
@@ -880,63 +843,15 @@ impl TextContent {
|
||||
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
||||
let mut has_text = false;
|
||||
for span in paragraph.children() {
|
||||
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
|
||||
let mut style = span.to_style(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
false,
|
||||
paragraph.line_height(),
|
||||
);
|
||||
let mut paint = paint::Paint::default();
|
||||
match span.fills_from_bottom(layer) {
|
||||
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
|
||||
paint.set_color(skia::Color::BLACK);
|
||||
paint.set_alpha(255);
|
||||
}
|
||||
_ => {
|
||||
paint.set_color(skia::Color::TRANSPARENT);
|
||||
}
|
||||
}
|
||||
style.set_foreground_paint(&paint);
|
||||
style
|
||||
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
|
||||
let skip_span = matches!(
|
||||
span.fills_from_bottom(layer),
|
||||
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
|
||||
);
|
||||
if skip_span {
|
||||
let mut style = span.to_style(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
false,
|
||||
paragraph.line_height(),
|
||||
);
|
||||
let mut paint = paint::Paint::default();
|
||||
paint.set_color(skia::Color::TRANSPARENT);
|
||||
style.set_foreground_paint(&paint);
|
||||
style
|
||||
} else {
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
)
|
||||
};
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
let text_style = span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
);
|
||||
let text: String = span.apply_text_transform();
|
||||
if !text.is_empty() {
|
||||
has_text = true;
|
||||
@@ -956,14 +871,8 @@ impl TextContent {
|
||||
/// Performs an Auto Width text layout.
|
||||
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
|
||||
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
|
||||
let mut measure_builders = self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
Some(skia::textlayout::TextAlign::Left),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut measure_builders =
|
||||
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
|
||||
|
||||
let normalized_line_height =
|
||||
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
|
||||
@@ -1555,15 +1464,6 @@ pub struct TextSpan {
|
||||
}
|
||||
|
||||
impl TextSpan {
|
||||
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
|
||||
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
|
||||
if layer < self.fills.len() {
|
||||
Some(&self.fills[self.fills.len() - 1 - layer])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
text: String,
|
||||
|
||||
@@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet};
|
||||
use crate::uuid::Uuid;
|
||||
use skia::Matrix;
|
||||
|
||||
/// Axes the pixel grid rounds. An axis-locked drag rounds only the axis it
|
||||
/// moves along.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
pub enum PixelPrecision {
|
||||
Disabled,
|
||||
Both,
|
||||
OnlyX,
|
||||
OnlyY,
|
||||
}
|
||||
|
||||
impl PixelPrecision {
|
||||
pub fn enabled(&self) -> bool {
|
||||
*self != PixelPrecision::Disabled
|
||||
}
|
||||
|
||||
pub fn rounds_x(&self) -> bool {
|
||||
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyX)
|
||||
}
|
||||
|
||||
pub fn rounds_y(&self) -> bool {
|
||||
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyY)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for PixelPrecision {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
1 => PixelPrecision::Both,
|
||||
2 => PixelPrecision::OnlyX,
|
||||
3 => PixelPrecision::OnlyY,
|
||||
_ => PixelPrecision::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Clone)]
|
||||
pub enum Modifier {
|
||||
Transform(TransformEntry, bool),
|
||||
Transform(TransformEntry, PixelPrecision),
|
||||
Reflow(Uuid, bool),
|
||||
}
|
||||
|
||||
impl Modifier {
|
||||
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
|
||||
Modifier::Transform(TransformEntry::from_propagate(id, transform), false)
|
||||
Modifier::Transform(
|
||||
TransformEntry::from_propagate(id, transform),
|
||||
PixelPrecision::Disabled,
|
||||
)
|
||||
}
|
||||
pub fn parent(id: Uuid, transform: Matrix) -> Self {
|
||||
Modifier::Transform(TransformEntry::parent(id, transform), false)
|
||||
Modifier::Transform(
|
||||
TransformEntry::parent(id, transform),
|
||||
PixelPrecision::Disabled,
|
||||
)
|
||||
}
|
||||
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
|
||||
Modifier::Reflow(id, force_reflow)
|
||||
|
||||
@@ -403,6 +403,20 @@ impl ShapesPoolImpl {
|
||||
/// gone, but if we don't touch their tiles they keep pointing at the
|
||||
/// previous modified position and the tile texture cache may serve stale
|
||||
/// pixels.
|
||||
/// Drops the transform modifiers, keeping structure and scale-content
|
||||
/// entries, so the pool serves committed geometry again. Called before
|
||||
/// propagating a new set of transforms, which are relative to that
|
||||
/// geometry.
|
||||
pub fn clear_transform_modifiers(&mut self) {
|
||||
if self.modifiers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.clean_shape_cache();
|
||||
self.modifiers = HashMap::default();
|
||||
self.modifier_uuids.clear();
|
||||
}
|
||||
|
||||
pub fn clean_all(&mut self) -> Vec<Uuid> {
|
||||
self.clean_shape_cache();
|
||||
|
||||
|
||||
@@ -140,22 +140,6 @@ pub extern "C" fn store_image() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Registers the public URL an image was loaded from for SVG export.
|
||||
///
|
||||
/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
|
||||
/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let url_bytes = mem::bytes();
|
||||
let url = String::from_utf8(url_bytes)
|
||||
.map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
|
||||
mem::free_bytes()?;
|
||||
get_resources().images.set_source_url(id, url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stores an image from an existing WebGL texture, avoiding re-decoding
|
||||
/// Expected memory layout:
|
||||
/// - bytes 0-15: shape UUID
|
||||
|
||||
@@ -76,7 +76,7 @@ impl From<RawTransformEntry> for TransformEntry {
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> {
|
||||
pub extern "C" fn propagate_modifiers(pixel_precision: u8) -> Result<*mut u8> {
|
||||
let bytes = mem::bytes();
|
||||
|
||||
let entries: Vec<TransformEntry> = bytes
|
||||
@@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8>
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
with_state!(state, {
|
||||
let result = shapes::propagate_modifiers(state, &entries, pixel_precision)?;
|
||||
state.shapes.clear_transform_modifiers();
|
||||
let result = shapes::propagate_modifiers(state, &entries, pixel_precision.into())?;
|
||||
Ok(mem::write_vec(result))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user