Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-06-25 09:33:15 +02:00
commit 2eb9423963
7 changed files with 219 additions and 128 deletions

View File

@ -78,6 +78,11 @@
text-transform-attrs
text-fills))
(def text-span-attrs
"Inline text span attrs. Line-height is paragraph-level in the DOM editor;
it may still be stored redundantly on span nodes."
(vec (remove #{:line-height} text-node-attrs)))
(defn text-node-attr?
[attr]
(d/index-of text-node-attrs attr))
@ -317,9 +322,16 @@
"Given two content text structures, conformed by maps and vectors,
compare them, and returns a set with the attributes that have changed.
This is independent of the text structure, so if the structure changes
but the attributes are the same, it will return an empty set."
but the attributes are the same, it will return an empty set.
Line-height on text nodes is ignored: it is a paragraph-level attribute
and may be stored redundantly on spans (e.g. after token apply)."
[a b]
(let [diff-attrs (compare-text-content a b
(let [strip-span-line-height
#(transform-nodes is-text-node? (fn [node] (dissoc node :line-height)) %)
a (strip-span-line-height a)
b (strip-span-line-height b)
diff-attrs (compare-text-content a b
{:text-cb identity
:attribute-cb (fn [acc attr] (conj acc attr))})]
(if-not (contains? diff-attrs :text-content-structure)

View File

@ -76,8 +76,31 @@
(assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial"))
(def content-changed-line-height
(assoc-in content-base [:children 0 :children 0 :line-height] "1.5"))
(def content-redundant-span-line-height
(assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5"))
;; Token apply may store line-height on paragraph and spans; after a DOM
;; round-trip spans no longer carry it (paragraph-level in the editor).
(def content-token-like-line-height
(-> content-base
(assoc-in [:children 0 :children 0 :line-height] 1.4)
(assoc-in [:children 0 :children 0 :children 0 :line-height] 1.4)))
(def content-after-editor-roundtrip
(update-in content-token-like-line-height
[:children 0 :children 0 :children 0]
dissoc :line-height))
;; from_dom used to merge default nil typography refs on import.
(def content-explicit-nil-typography-refs
(-> content-base
(assoc-in [:children 0 :children 0 :typography-ref-id] nil)
(assoc-in [:children 0 :children 0 :typography-ref-file] nil)
(assoc-in [:children 0 :children 0 :children 0 :typography-ref-id] nil)
(assoc-in [:children 0 :children 0 :children 0 :typography-ref-file] nil)))
(def content-changed-letter-spacing
(assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2"))
@ -185,6 +208,10 @@
;; Other text-node-attr categories
attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family)
attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height)
attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height)
attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height
content-after-editor-roundtrip)
attrs-nil-typography-refs (cttx/get-diff-attrs content-base content-explicit-nil-typography-refs)
attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing)
attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration)
attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform)
@ -215,6 +242,9 @@
;; Each text-node-attr category reports correct attr key
(t/is (= #{:font-family} attrs-font-family))
(t/is (= #{:line-height} attrs-line-height))
(t/is (= #{} attrs-span-line-height))
(t/is (= #{} attrs-roundtrip-line-height))
(t/is (= #{} attrs-nil-typography-refs))
(t/is (= #{:letter-spacing} attrs-letter-spacing))
(t/is (= #{:text-decoration} attrs-text-decoration))
(t/is (= #{:text-transform} attrs-text-transform))

View File

@ -51,13 +51,19 @@
[_ style-decode] (get styles/mapping key)]
(style-decode (.getPropertyValue style style-name)))
(let [style-name (styles/get-style-name key)]
(styles/normalize-attr-value key (.getPropertyValue style style-name))))]
(assoc acc key (if (value-empty? value) (get defaults key) value))))
(styles/normalize-attr-value key (.getPropertyValue style style-name))))
default (get defaults key)
final-value (if (value-empty? value) default value)]
;; Omit attrs with no CSS value when the default is nil (e.g.
;; typography-ref-id). Avoids polluting round-tripped content.
(if (and (value-empty? value) (nil? default))
acc
(assoc acc key final-value))))
{} attrs)))
(defn get-text-span-styles
[element]
(get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs)))
(get-attrs-from-styles element txt/text-span-attrs (txt/get-default-text-attrs)))
(defn get-paragraph-styles
[element]

View File

@ -94,9 +94,8 @@
(defn get-text-span-styles
[inline paragraph]
(let [node (if (= "" (:text inline)) paragraph inline)
styles (get-styles-from-attrs node txt/text-node-attrs txt/default-text-attrs)]
(dissoc styles :line-height)))
(let [node (if (= "" (:text inline)) paragraph inline)]
(get-styles-from-attrs node txt/text-span-attrs txt/default-text-attrs)))
(defn normalize-spaces
"Add zero-width spaces after forward slashes to enable word breaking"

View File

@ -2003,89 +2003,12 @@ impl RenderState {
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let bg_color = self.background_color;
// During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
// always prefer the persistent atlas. The atlas is incrementally updated as tiles finish,
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let interest = self.options.dpr_viewport_interest_area_threshold;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr;
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr;
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
// For zoom-out, prefer cache only if it fully covers the viewport.
// Otherwise, atlas will provide a more correct full-viewport preview.
let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom;
if zooming_out {
let cache_dim = self.surfaces.cache_dimensions();
let cache_w = cache_dim.width as f32;
let cache_h = cache_dim.height as f32;
// Viewport in target pixels.
let vw = self.viewbox.dpr_width().max(1.0);
let vh = self.viewbox.dpr_height().max(1.0);
// Inverse-map viewport corners into cache coordinates.
// target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
// => cache = (target / navigate_zoom) - translate
let inv = if navigate_zoom.abs() > f32::EPSILON {
1.0 / navigate_zoom
} else {
0.0
};
// let cx0 = (0.0 * inv) - translate_x;
// let cy0 = (0.0 * inv) - translate_y;
// NOTA: 0.0 * inv => siempre 0
let cx0 = -translate_x;
let cy0 = -translate_y;
let cx1 = (vw * inv) - translate_x;
let cy1 = (vh * inv) - translate_y;
let min_x = cx0.min(cx1);
let min_y = cy0.min(cy1);
let max_x = cx0.max(cx1);
let max_y = cy0.max(cy1);
let cache_covers =
min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h;
if !cache_covers {
// Early return only if atlas exists; otherwise keep cache path.
if !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
}
}
// Draw directly from cache surface, avoiding snapshot overhead
self.surfaces.draw_cache_to_backbuffer();
self.present_frame(shapes);
}
self.surfaces.draw_combined_atlas_to_backbuffer(
&self.viewbox,
&self.tile_viewbox,
self.background_color,
);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);

View File

@ -28,13 +28,17 @@ const CANVAS_BORDER_RADIUS: f32 = 12.0;
// distinct offsets for the two and we mirror that.
const SELECTION_LABEL_BASELINE: f32 = 13.6;
// Selection-label gradient mask: matches the SVG `selection-gradient-start`
// and `selection-gradient-end` defs. The mask is `OVER_NUMBER_SIZE` screen
// pixels long, with the opaque part starting `OVER_NUMBER_PERCENT` of the
// way through the rect (40% from the outside edge, 60% from the inside).
// Selection-label gradient mask: fades the tick labels behind the selection
// band. The mask is `OVER_NUMBER_SIZE` screen pixels long. `OVER_NUMBER_PERCENT`
// is the fraction of the mask that sits *outside* the band; at 1.0 the mask's
// inner edge lands exactly on the band edge, so the dark shadow begins where
// the green selection area ends and fades outward (it never bleeds under the
// band). `GRADIENT_FADE_FRACTION` is the share of the mask spent on the
// transparent→opaque ramp at the outer end.
const OVER_NUMBER_SIZE: f32 = 100.0;
const OVER_NUMBER_PERCENT: f32 = 0.75;
const OVER_NUMBER_PERCENT: f32 = 1.0;
const GRADIENT_FADE_FRACTION: f32 = 0.4;
const OVER_NUMBER_OPACITY: f32 = 0.8;
fn calculate_step_size(zoom: f32) -> f32 {
if zoom <= 0.0 {
@ -308,23 +312,27 @@ fn draw_selection_x(ctx: &RenderCtx, sel: Rect, offset: f32) {
);
let text_y = ctx.vy + SELECTION_LABEL_BASELINE * zi;
let pad_x = 4.0 * zi;
// Both labels use the same half-bar gap from the band so the start (left)
// and end (right) are spaced symmetrically.
let gap = (RULER_AREA_SIZE / 2.0) * zi;
let left_label = format_label(sel.left - offset);
let right_label = format_label(sel.right - offset);
let (lw_font, _) = ctx.font.measure_str(&left_label, None);
// The right label is anchored at its left edge, so we don't need its
// measured width.
let lx = sel.left - pad_x - lw_font * zi;
let rx = sel.right + pad_x;
let lx = sel.left - gap - lw_font * zi;
let rx = sel.right + gap;
let mut text_paint = Paint::default();
text_paint.set_color(ctx.state.accent_color);
text_paint.set_anti_alias(true);
// 1. Left label
canvas.save();
canvas.translate((lx, text_y));
canvas.scale((zi, zi));
canvas.draw_str(&left_label, Point::new(0.0, 0.0), ctx.font, &text_paint);
canvas.restore();
// 2. Right label
canvas.save();
canvas.translate((rx, text_y));
canvas.scale((zi, zi));
@ -341,7 +349,7 @@ enum MaskAxis {
/// labels behind the selection band. `fade_to_end` flips it from
/// transparent→opaque (before the band) to opaque→transparent (after).
fn draw_mask(ctx: &RenderCtx, rect: Rect, axis: MaskAxis, fade_to_end: bool) {
let opaque = ctx.state.bg_color;
let opaque = with_alpha(ctx.state.bg_color, OVER_NUMBER_OPACITY);
let transparent = with_alpha(ctx.state.bg_color, 0.0);
let (colors, offsets): (&[skia::Color; 3], &[f32; 3]) = if fade_to_end {
(
@ -377,11 +385,10 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) {
let canvas = ctx.canvas;
let zi = ctx.zi;
let pad_y = 4.0 * zi;
let top_label = format_label(sel.top - offset);
let bottom_label = format_label(sel.bottom - offset);
// Top label's draw position doesn't depend on its own width (LX is just
// pad_y/zi), so we only need bw_font for the bottom label's right-anchor.
// Both labels sit a half-bar gap from the band; only the bottom label's
// origin depends on its own width (it reads toward the band).
let (bw_font, _) = ctx.font.measure_str(&bottom_label, None);
// Mask first (gradient bg over tick labels behind), then band, then
@ -416,33 +423,27 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) {
let mut text_paint = Paint::default();
text_paint.set_color(ctx.state.accent_color);
text_paint.set_anti_alias(true);
// Both labels read bottom-to-top on screen (after the -90° rotation
// local +x points upward). With the transform stack
// (translate→rotate→scale) and a draw at code-(LX, 0), the actual
// origin in document coords is (text_x, pivot_y LX·zi).
//
// Top label: want origin just above sel.top, reading upward from
// there, so pivot_y LX·zi = sel.top pad_y ⇒ LX = pad_y/zi.
// Both labels read bottom-to-top on screen
// 1. Top label
canvas.save();
canvas.translate((text_x, sel.top));
canvas.rotate(-90.0, None);
canvas.scale((zi, zi));
canvas.draw_str(
&top_label,
Point::new(pad_y / zi, 0.0),
Point::new(RULER_AREA_SIZE / 2.0, 0.0),
ctx.font,
&text_paint,
);
canvas.restore();
// Bottom label: want the text END at sel.bottom + pad_y and origin
// at sel.bottom + pad_y + bw so it reads upward toward the band.
// 2. Bottom label
canvas.save();
canvas.translate((text_x, sel.bottom));
canvas.rotate(-90.0, None);
canvas.scale((zi, zi));
canvas.draw_str(
&bottom_label,
Point::new(-bw_font - pad_y / zi, 0.0),
Point::new(-bw_font - RULER_AREA_SIZE / 2.0, 0.0),
ctx.font,
&text_paint,
);

View File

@ -590,6 +590,57 @@ impl Surfaces {
canvas.restore();
}
/// Fast pan/zoom preview: draw the doc atlas as backdrop, then overlay HQ
/// cached tile textures placed via their stored document rects (pan + scale).
pub fn draw_combined_atlas_to_backbuffer(
&mut self,
viewbox: &Viewbox,
tile_viewbox: &TileViewbox,
background: skia::Color,
) {
self.draw_atlas_to_backbuffer(*viewbox, background);
// Tile textures are keyed by grid index but positioned in document space.
// Without `tile_doc_rects` we cannot displace/scale them correctly (e.g.
// right after zoom invalidation); the atlas backdrop alone is enough.
if self.atlas.tile_doc_rects.is_empty() {
return;
}
let batch = self.tiles.build_atlas_draw_batch_for_doc_rects(
viewbox,
tile_viewbox,
&self.atlas.tile_doc_rects,
);
if batch.is_empty() {
return;
}
if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() {
self.tile_atlas_image = Some(self.tile_atlas.image_snapshot());
self.tiles.snapshot();
}
let Some(atlas_image) = self.tile_atlas_image.as_ref() else {
return;
};
let canvas = self.backbuffer.canvas();
canvas.save();
canvas.reset_matrix();
canvas.draw_atlas(
atlas_image,
&batch.transforms,
&batch.textures,
None,
skia::BlendMode::SrcOver,
self.atlas_sampling_options,
None,
None,
);
canvas.restore();
}
pub fn margins(&self) -> skia::ISize {
self.margins
}
@ -716,18 +767,6 @@ impl Surfaces {
);
}
/// Draws the cache surface directly to the backbuffer canvas.
/// This avoids creating an intermediate snapshot, reducing GPU stalls.
pub fn draw_cache_to_backbuffer(&mut self) {
let sampling_options = self.sampling_options;
self.cache.draw(
self.backbuffer.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
pub fn cache_dimensions(&self) -> skia::ISize {
skia::ISize::new(self.cache.width(), self.cache.height())
}
@ -1516,6 +1555,17 @@ pub struct TileTextureCache {
removed: HashSet<Tile>,
}
pub struct AtlasDrawBatch {
pub transforms: Vec<skia::RSXform>,
pub textures: Vec<skia::Rect>,
}
impl AtlasDrawBatch {
pub fn is_empty(&self) -> bool {
self.transforms.is_empty()
}
}
impl TileTextureCache {
pub fn new(texture_size: i32, capacity: usize) -> Self {
Self {
@ -1615,6 +1665,76 @@ impl TileTextureCache {
}
}
pub fn build_atlas_draw_batch_for_doc_rects(
&self,
viewbox: &Viewbox,
tile_viewbox: &TileViewbox,
tile_doc_rects: &HashMap<Tile, skia::Rect>,
) -> AtlasDrawBatch {
let mut transforms = Vec::new();
let mut textures = Vec::new();
let s = viewbox.get_scale();
let view_doc = viewbox.area;
for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() {
for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() {
let tile = Tile(x, y);
let Some(tile_ref) = self.grid.get(&tile) else {
continue;
};
if self.removed.contains(&tile) {
continue;
}
let doc_rect = tile_doc_rects
.get(&tile)
.copied()
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue;
}
let scos = doc_rect.width() * s / self.tile_size;
let tx = (doc_rect.left + viewbox.pan.x) * s;
let ty = (doc_rect.top + viewbox.pan.y) * s;
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
}
}
// Cached tiles from a previous zoom level use indices outside visible_rect;
// place them via their stored document rect, not the current grid walk above.
for (&tile, tile_ref) in &self.grid {
if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) {
continue;
}
let doc_rect = tile_doc_rects
.get(&tile)
.copied()
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue;
}
let tx = (doc_rect.left + viewbox.pan.x) * s;
let ty = (doc_rect.top + viewbox.pan.y) * s;
let scos = doc_rect.width() * s / self.tile_size;
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
}
AtlasDrawBatch {
transforms,
textures,
}
}
pub fn has(&self, tile: Tile) -> bool {
self.grid.contains_key(&tile) && !self.removed.contains(&tile)
}