✨ Give a synced copy text variants of its own font families

Sync and the variant switch can take a copy text's family from one side
and its weight from the other. The variant id goes with the weight, so
it can name a variant of the other family: the WASM renderer then picks
the closest variant, but the SVG renderer falls back to the regular one.

The frontend now gives each text content that a sync or a component swap
commits variants of its own families: a run whose variant is not one of
its family's takes the family's closest to its weight and style
(cttx/update-font-variants, fonts/find-closest-variant). The pure runner
has no fonts, so case BA leaves variant ids out of its comparison.

The merge also leaves alone a run that already shows the placed style,
so a main edit no longer rewrites the variant ids of a copy whose
family is its own; with a new family, the run still takes the placed
variant id.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  35.7 / 1.01
  edit-through-a-chain-of-dependents   68.9 /  65.2 / 0.95
  library-update-into-copies           83.1 /  75.1 / 0.90
  main-edit-into-10-copies              1.8 /   1.5 / 0.86
  main-edit-into-100-copies            15.4 /  15.1 / 0.98
  main-edit-into-1000-copies          153.1 / 151.4 / 0.99
  variant-switch-into-copies           32.1 /  30.4 / 0.95

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alonso.torres 2026-10-13 23:25:24 +02:00
parent 28fba0bd01
commit b5c3c82062
6 changed files with 167 additions and 26 deletions

View File

@ -86,7 +86,8 @@
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[clojure.test :as t]))
[clojure.test :as t]
[clojure.walk :as walk]))
(def ^:private red "#ff0000")
(def ^:private green "#00ff00")
@ -705,6 +706,15 @@
thtx/colors))
:font-family #(thtx/restyle % :family value))))
(defn- without-variants
"`text` in the notation with no `:font-variant-id`."
[text]
(walk/postwalk #(cond-> % (map? %) (dissoc :font-variant-id)) text))
(defn- normalize
[text]
(without-variants (thtx/normalize text)))
(defn text-style-override-against-main-style-change
"Case BA. A main text in two styles (\"Price: \" and a bold \"$10\") and
its copy. The copy changes the fill or the font family of all of its text,
@ -726,34 +736,32 @@
:asserter (fn [s]
(let [copy-node (tm/get-choice s copy-edit)
main-node (tm/get-choice s main-edit)
same? (= (:edit copy-node) (:edit main-node))
main-text ((text-restyle main-node) setup/two-style-text)
merged (cond-> ((text-restyle copy-node) main-text)
;; the weight, from the main, keeps the
;; bold's variant id, for the frontend to
;; pick the copy's family's
(and (= :font-family (:edit copy-node)) (not same?))
(update-in [0 1 1] assoc :font-variant-id "bold"))
merged ((text-restyle copy-node) main-text)
copy (setup/copy-instance s)
main (setup/main-instance s)
text-of #(n/text-span-value % :content)]
;; the variant id is left out: with the family from
;; one side and the weight from the other, the
;; frontend picks the family's closest variant, and
;; the pure runner has no fonts to pick from
text-of #(without-variants (n/text-span-value % :content))]
(cond
(tm/applied? s reset)
(do
(t/is (= (thtx/normalize main-text) (text-of copy)))
(t/is (= (thtx/normalize main-text) (text-of main)))
(t/is (= (normalize main-text) (text-of copy)))
(t/is (= (normalize main-text) (text-of main)))
(t/is (empty? (:touched copy))))
(tm/applied? s push)
(do
(t/is (= (thtx/normalize merged) (text-of main)))
(t/is (= (normalize merged) (text-of main)))
(t/is (= (text-of main) (text-of copy)))
(t/is (empty? (:touched copy))))
:else
(do
(t/is (= (thtx/normalize main-text) (text-of main)))
(t/is (= (thtx/normalize merged) (text-of copy)))))))}))
(t/is (= (normalize main-text) (text-of main)))
(t/is (= (normalize merged) (text-of copy)))))))}))
;; ---------------------------------------------------------------------------
;; Swap outcomes

View File

@ -913,10 +913,17 @@
and on a paragraph when it has one or no run of it has."
[dst placed drop-typo?]
(let [run-units (filterv #(= :run (get-in style-units [% :level])) (keys placed))
;; a run that already shows the placed style keeps its own values;
;; but its variant id names a font of its own family, so with a new
;; family it takes the placed one
family? (contains? placed :text-content-family)
write-run (fn [node styles]
(cond-> (reduce (fn [node [unit style]]
(write-style node style (get-in style-units [unit :attrs])
(= unit :text-content-line-height)))
(let [attrs (get-in style-units [unit :attrs])]
(if (and (= (style-key unit (select-keys node attrs)) (style-key unit style))
(not (and family? (= unit :text-content-weight))))
node
(write-style node style attrs (= unit :text-content-line-height)))))
node
(map vector run-units styles))
drop-typo? drop-typography))
@ -1101,6 +1108,20 @@
(not same-letters?)
(conj :text-content-text))))
(defn update-font-variants
"`content` with the variant id of each node that names a font set to
`(variant-of node)`, when that gives one. Sync and the variant switch
can take a text's family from one side and its weight from the other,
leaving a variant id of another family; the frontend, which knows the
fonts, picks the family's closest variant through this."
[content variant-of]
(transform-nodes #(and (is-content-node? %) (some? (:font-id %)))
(fn [node]
(if-some [variant-id (variant-of node)]
(assoc node :font-variant-id variant-id)
node))
content))
(defn content-has-text?
[content search]
(let [search-lower (str/lower search)]

View File

@ -107,3 +107,10 @@
(t/is (not (cttx/same-style? {:font-size "14"} {:font-size "18"})))
(t/is (not (cttx/same-style? {:font-variant-id "700"} {:font-variant-id "700italic"})))
(t/is (not (cttx/same-style? {:fills [{:fill-color "#000000" :fill-opacity 1}]} {}))))
(t/deftest font-variants-of-the-nodes-that-name-a-font
(let [content (thtx/content [[["Price: "] ["$10" {:weight 700}]]])
fixed (cttx/update-font-variants content #(when (= "bold" (:font-variant-id %)) "700"))]
(t/is (= [[["Price: "] ["$10" {:weight 700 :font-variant-id "700"}]]] (thtx/view fixed)))
(t/testing "the paragraph, which holds its first run's styles, keeps them"
(t/is (= "regular" (get-in fixed [:children 0 :children 0 :font-variant-id]))))))

View File

@ -26,6 +26,7 @@
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.library :as ctl]
[app.common.types.text :as cttx]
[app.common.types.typography :as ctt]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -53,6 +54,7 @@
[app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
@ -980,6 +982,32 @@
(rx/of (dwt.wasm/render-thumbnail file-id page-id root-id :persist? true)))
(rx/of (update-component-thumbnail-sync state component-id file-id "component"))))))
(defn- closest-font-variant
"The id of the variant of the family of the text node `node` closest to its
weight and style, when its variant is not one of the family's."
[node]
(when-let [font (fonts/get-font-data (:font-id node))]
(when-not (d/seek #(= (:id %) (:font-variant-id node)) (:variants font))
(:id (fonts/find-closest-variant font (:font-weight node) (:font-style node))))))
(defn fix-font-variants
"`changes` with each text content they set given variants of its own
families: a sync or a variant switch can take a copy text's family from
one side and its weight from the other (`mem:common/component-sync-contract`)."
[changes]
(let [fix-operation
(fn [{:keys [type attr val] :as operation}]
(if (and (= type :set) (= attr :content) (cttx/is-root-node? val))
(update operation :val cttx/update-font-variants closest-font-variant)
operation))]
(update changes :redo-changes
(fn [redo-changes]
(mapv (fn [change]
(cond-> change
(= :mod-obj (:type change))
(update :operations #(mapv fix-operation %))))
redo-changes)))))
(defn component-swap
"Swaps a component with another one"
[shape file-id id-new-component keep-touched?]
@ -1016,7 +1044,7 @@
update-layout-ids (concat update-layout-ids new-children-ids)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(dch/commit-changes (fix-font-variants changes))
(dwtp/propagate-workspace-tokens) ;; Make the new instance get the token values from the current file, not from the component's library
(when (and (features/active-feature? state "render-wasm/v1")
(seq new-text-ids))
@ -1138,15 +1166,16 @@
libraries (dsh/lookup-libraries state)
current-file-id (:current-file-id state)
changes (cll/generate-sync-file-changes
(pcb/empty-changes it)
undo-group
asset-type
file-id
asset-id
library-id
libraries
current-file-id)
changes (-> (cll/generate-sync-file-changes
(pcb/empty-changes it)
undo-group
asset-type
file-id
asset-id
library-id
libraries
current-file-id)
(fix-font-variants))
find-frames (fn [change]
(->> (ch/frames-changed ldata change)

View File

@ -0,0 +1,74 @@
;; 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.data.workspace-font-variants-test
"A sync that takes a copy text's family from the copy and its weight from
the main gives each run a variant of its own family: the closest to its
weight and style."
(:require
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.test-helpers.texts :as cthtx]
[app.common.types.text :as cttx]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.fonts :as fonts]
[cljs.test :as t :include-macros true]
[frontend-tests.composable-tests.interpreter :as ftm]))
(def ^:private test-font
{:id "test-font"
:family "Test"
:name "Test"
:variants [{:id "400" :name "400" :weight "400" :style "normal"}
{:id "700" :name "700" :weight "700" :style "normal"}]})
(t/use-fixtures :each
{:before (fn []
((:before ftm/fixture))
(fonts/register! :test-fonts [test-font]))
:after (fn []
(fonts/register! :test-fonts [])
((:after ftm/fixture)))})
(def ^:private in-test-font
{:font-family "Test" :font-id "test-font"})
(defn- run-variants
[label]
(->> (cttx/node-seq cttx/is-text-node? (:content (cths/get-shape (ftm/current-file) label)))
(mapv (juxt :text :font-variant-id))))
(t/deftest fix-font-variants-picks-the-family-closest
(let [content [[["Price: " (assoc in-test-font :font-variant-id "regular")]
["$10" (assoc in-test-font :weight 700 :font-variant-id "bold")]
["!" {:font-variant-id "bold" :weight 700}]]]
changes {:redo-changes [{:type :mod-obj
:id (cthi/new-id! :text)
:operations [{:type :set :attr :content :val (cthtx/content content)}
{:type :set :attr :name :val "Price"}]}]}
fixed (-> (dwl/fix-font-variants changes) :redo-changes first :operations)]
(t/is (= [["Price: " "400"] ["$10" "700"] ["!" "bold"]]
(mapv (juxt :text :font-variant-id)
(cttx/node-seq cttx/is-text-node? (:val (first fixed))))))
(t/is (= {:type :set :attr :name :val "Price"} (second fixed)))))
(t/deftest ^:async a-sync-gives-the-copy-family-variants
(ftm/install! (tm/make-situation
(-> (cthf/sample-file :file1)
(cthtx/add-text-component-with-copy [[["Price: "] ["$10" {:weight 700}]]])
;; the copy changes its family, as the editor does
(cthtx/set-text :copy-child [[["Price: " (assoc in-test-font :font-variant-id "400")]
["$10" (assoc in-test-font :weight 700 :font-variant-id "700")]]]))))
(t/is (= [["Price: " "400"] ["$10" "700"]] (run-variants :copy-child)))
;; the main makes all of it bold: the weight comes from the main, with a
;; variant of the main's family ("bold")
(t/is (= :settled (await (ftm/await-step [(dwsh/update-shapes #{(cthi/id :main-child)}
#(assoc % :content (cthtx/content [[["Price: " {:weight 700}]
["$10" {:weight 700}]]])))]))))
(t/is (= [["Price: $10" "700"]] (run-variants :copy-child))))

View File

@ -32,6 +32,7 @@
[frontend-tests.data.workspace-context-menu-test]
[frontend-tests.data.workspace-edit-watchers-test]
[frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-font-variants-test]
[frontend-tests.data.workspace-libraries-test]
[frontend-tests.data.workspace-library-links-test]
[frontend-tests.data.workspace-library-updates-test]
@ -197,6 +198,7 @@
'frontend-tests.data.workspace-context-menu-test
'frontend-tests.data.workspace-edit-watchers-test
'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-font-variants-test
'frontend-tests.data.workspace-libraries-test
'frontend-tests.data.workspace-library-links-test
'frontend-tests.data.workspace-library-updates-test