From a8b457a1d36a3ae6f2aa0c4dd10cec9ec04df17b Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 7 Sep 2026 16:29:03 +0200 Subject: [PATCH] :bug: Fix empty text content breaking file persistence --- .../src/app/main/data/workspace/texts.cljs | 56 ++++++++++++------ frontend/src/app/render_wasm/api.cljs | 24 ++++---- frontend/src/app/render_wasm/text_editor.cljs | 59 ++++++++++--------- 3 files changed, 80 insertions(+), 59 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index dd8741218d..9742453d29 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -192,30 +192,50 @@ #{:font-family :font-size :font-style :font-weight :direction :text-direction :text-decoration :text-transform :key}) -(defn- remove-nil-style-attrs - "Strip nil-valued non-nilable style attrs from every node in a content tree. - Repairs content already corrupted with e.g. nil :font-family/:font-weight/ - :font-style (from an unloaded font) so it can pass the backend schema again." +(defn- repair-nodes + "Walk a content tree once, repairing every node so it can pass the backend + `validate-shape` schema: nil-valued non-nilable style attrs are stripped + (e.g. nil :font-family/:font-weight/:font-style from an unloaded font) and a + level with no children is seeded with the default paragraph/span." [content] - (txt/transform-nodes - (fn [node] - (reduce (fn [node k] - (if (and (contains? node k) (nil? (get node k))) - (dissoc node k) - node)) - node - non-nilable-style-attrs)) - content)) + (let [;; Seeding only makes sense inside a root tree: a bare node handed over + ;; as content is not ours to complete. + seed? (= "root" (:type content)) + default-para (delay (-> (tc/v2-default-text-content) :children first :children first))] + (txt/transform-nodes + (fn [node] + (let [node (reduce (fn [node k] + (if (and (contains? node k) (nil? (get node k))) + (dissoc node k) + node)) + node + non-nilable-style-attrs)] + (if seed? + (case (:type node) + "paragraph-set" + (cond-> node + (empty? (:children node)) + (assoc :children [@default-para])) + + "paragraph" + (cond-> node + (empty? (:children node)) + (assoc :children (:children @default-para))) + + node) + node))) + content))) (defn ensure-valid-text-content "Repair structurally incomplete text :content to a canonical root -> paragraph-set -> paragraph -> span tree. Returns the content unchanged when it is already well-formed. - A `nil` content, a root with no :children, or a root with an empty - :children vector all fail the backend `validate-shape` schema - (children must contain at least one paragraph-set). This helper - is the defensive normalizer used by content-commit paths. + A `nil` content, a root with no :children, a paragraph-set with no + paragraphs, or a paragraph with no spans all fail the backend + `validate-shape` schema (every level requires at least one child). + This helper is the defensive normalizer used by content-commit paths; + it mirrors the `0025-repair-empty-text-content` migration. It also scrubs nil-valued non-nilable style attrs (e.g. nil :font-family/:font-weight/:font-style left over from an unloaded font), @@ -227,7 +247,7 @@ (empty? (:children content)))) (let [base (tc/v2-default-text-content)] (d/txt-merge base (select-keys content txt/root-attrs))) - (remove-nil-style-attrs content))) + (repair-nodes content))) (defn- v2-content-has-text? [content] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c24bcf0460..7b9bf963bd 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1278,22 +1278,18 @@ (if (< index total) (let [paragraph (nth paragraphs index) - spans (get paragraph :children)] - (if (empty? (seq spans)) - (recur (inc index) - emoji? - langs) + spans (or (seq (get paragraph :children)) + [(assoc (select-keys paragraph txt/text-node-attrs) :text "")]) + text (apply str (map :text spans)) + emoji? (if emoji? emoji? (t/contains-emoji? text)) + langs (t/collect-used-languages langs text)] - (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (t/contains-emoji? text)) - langs (t/collect-used-languages langs text)] + ;; FIXME: this should probably be somewhere else + (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) - ;; FIXME: this should probably be somewhere else - (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) - - (recur (inc index) - emoji? - langs)))) + (recur (inc index) + emoji? + langs)) (let [updated-fonts (-> #{} diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index b577861f62..1d6c1b9aa0 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -550,32 +550,37 @@ exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]] content existing Penpot content map (root -> paragraph-set -> …)" [content exported-texts] - (let [para-set (first (get content :children)) - orig-paras (get para-set :children) - num-orig (count orig-paras) - last-orig-para (when (seq orig-paras) (last orig-paras)) - template-span (when last-orig-para - (-> last-orig-para :children last)) - new-paras - (mapv (fn [para-idx exported-span-texts] - (let [orig-para (if (< para-idx num-orig) - (nth orig-paras para-idx) - (dissoc last-orig-para :children)) - orig-spans (get orig-para :children) - num-orig-spans (count orig-spans) - last-orig-span (when (seq orig-spans) (last orig-spans))] - (assoc orig-para :children - (mapv (fn [span-idx new-text] - (let [orig-span (if (< span-idx num-orig-spans) - (nth orig-spans span-idx) - (or last-orig-span template-span))] - (assoc orig-span :text new-text))) - (range (count exported-span-texts)) - exported-span-texts)))) - (range (count exported-texts)) - exported-texts) - new-para-set (assoc para-set :children new-paras)] - (assoc content :children [new-para-set]))) + (if (empty? exported-texts) + content + (let [para-set (first (get content :children)) + orig-paras (get para-set :children) + num-orig (count orig-paras) + last-orig-para (when (seq orig-paras) (last orig-paras)) + template-span (when last-orig-para + (-> last-orig-para :children last)) + exported-texts (mapv (fn [span-texts] + (if (empty? span-texts) [""] span-texts)) + exported-texts) + new-paras + (mapv (fn [para-idx exported-span-texts] + (let [orig-para (if (< para-idx num-orig) + (nth orig-paras para-idx) + (dissoc last-orig-para :children)) + orig-spans (get orig-para :children) + num-orig-spans (count orig-spans) + last-orig-span (when (seq orig-spans) (last orig-spans))] + (assoc orig-para :children + (mapv (fn [span-idx new-text] + (let [orig-span (if (< span-idx num-orig-spans) + (nth orig-spans span-idx) + (or last-orig-span template-span))] + (assoc orig-span :text new-text))) + (range (count exported-span-texts)) + exported-span-texts)))) + (range (count exported-texts)) + exported-texts) + new-para-set (assoc para-set :children new-paras)] + (assoc content :children [new-para-set])))) (defn- default-empty-text-content "Build a default, empty text content tree used as a merge template. @@ -687,7 +692,7 @@ paragraphs (:children paragraph-set) new-paragraphs - (when (not collapsed?) + (when (and (not collapsed?) (seq paragraphs)) (mapv (fn [idx para] (cond ;; paragraph outside the range of paragraphs.