From 2bb88baeb672107acc9485d9a06a16827ba2f7fc Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 17:11:17 +0200 Subject: [PATCH] :sparkles: Preserve Japanese text in browser rendering and export Render Japanese annotations in HTML and SVG and expose their styles in CSS code generation. Preserve vertical position data from WASM, while keeping the legacy SVG renderer on its horizontal layout path. Cover generated markup, styles and copied SVG output. --- .../src/app/main/ui/shapes/text/fo_text.cljs | 22 +- .../app/main/ui/shapes/text/html_text.cljs | 28 ++- .../src/app/main/ui/shapes/text/styles.cljs | 156 ++++++++++++- .../src/app/main/ui/shapes/text/svg_text.cljs | 215 ++++++++++++++++-- .../shapes/text/viewport_texts_html.cljs | 25 +- frontend/src/app/util/code_gen/style_css.cljs | 36 ++- frontend/src/app/util/text/writing_mode.cljs | 55 +++++ .../frontend_tests/code_gen_style_test.cljs | 186 ++++++++++++++- .../test/frontend_tests/copy_as_svg_test.cljs | 134 +++++++++++ 9 files changed, 803 insertions(+), 54 deletions(-) create mode 100644 frontend/src/app/util/text/writing_mode.cljs diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 13927d2fce..93e791cad1 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -10,7 +10,9 @@ [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] [app.common.types.color :as cc] + [app.common.types.text.cjk-layout :as cjk] [app.main.ui.shapes.text.styles :as sts] + [app.util.text.writing-mode :as wm] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -19,9 +21,14 @@ (let [text (:text node) style (if (= text "") (sts/generate-text-styles shape parent) - (sts/generate-text-styles shape node))] - [:span.text-node {:style style} - (if (= text "") "\u00A0" text)])) + (sts/generate-text-styles shape node)) + ruby (cjk/visible-ruby node)] + (if (some? ruby) + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} + [:span.text-node {:style style} (sts/text-children text node)] + [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] + [:span.text-node {:style style} + (if (= text "") "\u00A0" (sts/text-children text node))]))) (mf/defc render-root* [{:keys [node children shape]}] @@ -164,7 +171,10 @@ y (dm/get-prop shape :y) width (dm/get-prop shape :width) height (dm/get-prop shape :height) - content (get shape :content) + content (wm/renderable-content (get shape :content)) + + ;; Vertical text anchors columns to the box edges, so it skips the oversized auto-grow box. + vertical? (wm/vertical-text-content? content) [colors _color-mapping color-mapping-inverse] (retrieve-colors shape)] @@ -175,8 +185,8 @@ :data-colors (str/join "," colors) :data-mapping (-> color-mapping-inverse clj->js js/JSON.stringify) :transform transform - :width (if (#{:auto-width} grow-type) 100000 width) - :height (if (#{:auto-height :auto-width} grow-type) 100000 height) + :width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width) + :height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height) :ref ref} ;; We use a class here because react has a bug that won't use the appropriate selector for ;; `background-clip` diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 299ee49de7..f9663085c9 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -9,7 +9,9 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.text :as legacy.txt] + [app.common.types.text.cjk-layout :as cjk] [app.main.ui.shapes.text.styles :as sts] + [app.util.text.writing-mode :as wm] [rumext.v2 :as mf])) (mf/defc render-text* @@ -18,9 +20,17 @@ style (if (= text "") (sts/generate-text-styles shape parent) (sts/generate-text-styles shape node)) - class (when is-code (:$id node))] - [:span.text-node {:style style :class class} - (if (= text "") "\u00A0" text)])) + class (when is-code (:$id node)) + ruby (cjk/visible-ruby node)] + (if (some? ruby) + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node) + :class (when is-code (dm/str class "-ruby"))} + [:span.text-node {:style style :class class} (sts/text-children text node)] + [:rt {:style (sts/generate-ruby-styles shape node) + :class (when is-code (dm/str class "-rt"))} + ruby]] + [:span.text-node {:style style :class class} + (if (= text "") "\u00A0" (sts/text-children text node))]))) (mf/defc render-root* [{:keys [node children shape is-code]}] @@ -73,7 +83,13 @@ [{:keys [shape grow-type is-code]} ref] (let [{:keys [id x y width height content]} shape - content (if is-code (legacy.txt/index-content content) content) + ;; Generated code keeps the writing mode whatever the renderer. + content (if is-code + (legacy.txt/index-content content) + (wm/renderable-content content)) + + ;; Vertical text anchors columns to the box edges, so it skips the oversized auto-grow box. + vertical? (wm/vertical-text-content? content) style (when-not is-code @@ -81,8 +97,8 @@ :left 0 :top 0 :background "white" - :width (if (#{:auto-width} grow-type) 100000 width) - :height (if (#{:auto-height :auto-width} grow-type) 100000 height)})] + :width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width) + :height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height)})] [:div.text-node-html {:id (dm/str "html-text-node-" id) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 657857f667..19932dca3d 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -10,17 +10,23 @@ [app.common.transit :as transit] [app.common.types.color :as cc] [app.common.types.text :as txt] + [app.common.types.text.cjk-layout :as cjk] [app.main.fonts :as fonts] [app.main.ui.formats :as fmt] [app.util.color :as uc] [app.util.object :as obj] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [rumext.v2 :as mf])) (defn generate-root-styles + "Root styles. `code?` leaves out the box size, which generated code sets + elsewhere. The root writing mode makes paragraph blocks stack + right-to-left." ([props node] (generate-root-styles props node false)) ([{:keys [width height]} node code?] (let [valign (:vertical-align node "top") + writing-mode (cjk/content-writing-mode node) base #js {:height (when-not code? (fmt/format-pixels height)) :width (when-not code? (fmt/format-pixels width)) :display "flex" @@ -28,7 +34,8 @@ (cond-> base (= valign "top") (obj/set! "alignItems" "flex-start") (= valign "center") (obj/set! "alignItems" "center") - (= valign "bottom") (obj/set! "alignItems" "flex-end"))))) + (= valign "bottom") (obj/set! "alignItems" "flex-end") + (some? writing-mode) (obj/set! "writingMode" writing-mode))))) (defn generate-paragraph-set-styles [{:keys [grow-type] :as shape}] @@ -55,15 +62,107 @@ line-height (:line-height txt/default-typography)) - text-align (:text-align data "start") - base #js {;; Fix a problem when exporting HTML - :fontSize 0 - :lineHeight line-height - :margin 0}] + text-align (:text-align data "start") + writing-mode (:writing-mode data) + text-orientation (:text-orientation data) + base #js {;; Fix a problem when exporting HTML + :fontSize 0 + :lineHeight line-height + :margin 0}] (cond-> base (some? line-height) (obj/set! "lineHeight" line-height) - (some? text-align) (obj/set! "textAlign" text-align)))) + (some? line-height) (obj/set! "--paragraph-line-height" (str line-height)) + (some? text-align) (obj/set! "textAlign" text-align) + (some? writing-mode) (obj/set! "writingMode" writing-mode) + (some? writing-mode) (obj/set! "textSpacingTrim" "normal") + (= writing-mode "vertical-rl") (obj/set! "textAutospace" "normal") + (some? text-orientation) (obj/set! "textOrientation" text-orientation)))) + +(defn css-text-combine-upright + "CSS value for a persisted text-combine-upright, or nil for the digits + variants: browsers do not support CSS `digits `, so renderers wrap each + digit run (`cjk/digit-combine-segments`) in an `all` span instead." + [value] + (when-not (cjk/digit-combine? value) + value)) + +;; Style of a combined digit run inside a `digits` span. +(def tcy-run-style #js {:textCombineUpright "all"}) + +(defn text-children + "Text of a node, with each combined digit run of a `digits` tate-chu-yoko + in its own `all` span (class `tcy` for generated code)." + [text node] + (if-let [segments (cjk/digit-combine-segments text (:text-combine-upright node))] + (into-array + (for [[index [run combine?]] (d/enumerate segments)] + (if combine? + (mf/html [:span.tcy {:key index :style tcy-run-style} run]) + run))) + text)) + +(defn- set-value? + "True for a stored style value other than empty or \"none\"." + [value] + (and (string? value) (pos? (alength value)) (not= "none" value))) + +(defn font-feature-settings + "CSS `font-feature-settings` for a persisted font-features value, or nil." + [font-features] + (when (set-value? font-features) + (str/format "\"%s\"" font-features))) + +(defn- annotation-line-height + "CSS line height that adds the room of automatic annotation clearance (the + ruby at its size plus the emphasis marks) to the paragraph line height, + which paragraphs expose as `--paragraph-line-height`. A span's own line + height wins when larger, as in the renderer. Nil under `none`." + [data] + (when (= "auto" (:annotation-clearance data)) + (let [room (+ (if (cjk/visible-ruby data) (cjk/ruby-font-scale (:ruby-size data)) 0) + (if (set-value? (:text-emphasis data)) cjk/emphasis-font-scale 0)) + paragraph (str "var(--paragraph-line-height, " + (:line-height txt/default-typography) ")") + own (js/parseFloat (:line-height data)) + base (if (js/isNaN own) + paragraph + (str "max(" paragraph ", " own ")"))] + (when (pos? room) + (str "calc(" base " + " (fmt/format-number room) ")"))))) + +(defn- add-japanese-text-styles! + [style data] + (let [text-combine-upright (:text-combine-upright data) + text-emphasis (:text-emphasis data) + font-features (font-feature-settings (:font-features data)) + line-height (annotation-line-height data) + font-size (:font-size data)] + (cond-> style + (some? (css-text-combine-upright text-combine-upright)) + (obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright)) + + ;; Stored kebab values ("filled-dot") become the CSS " " + ;; pair ("filled dot"). + (set-value? text-emphasis) + (obj/set! "textEmphasis" (str/replace text-emphasis "-" " ")) + + (some? font-features) + (obj/set! "fontFeatureSettings" font-features) + + (some? line-height) + (obj/set! "lineHeight" line-height) + + ;; Warichu (割注): a half-size inline-block as wide as half the + ;; characters, so the browser wraps it into two sub-lines. + (cjk/warichu-text? data) + (-> (obj/set! "display" "inline-block") + (obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size))) + (str (* (js/parseFloat font-size) cjk/warichu-font-scale) "px") + "50%")) + (obj/set! "lineHeight" "1") + (obj/set! "inlineSize" + (str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))) (defn generate-text-styles ([shape data] @@ -154,4 +253,43 @@ (obj/set! "fontWeight" font-weight)) (= grow-type :auto-width) - (obj/set! "whiteSpace" "pre"))))) + (obj/set! "whiteSpace" "pre") + + :always + (add-japanese-text-styles! data))))) + +(defn- ruby-font-size + "Pixel size of a span's ruby text. Paragraphs set font-size 0, so a + relative size would resolve to nothing." + [data] + (let [font-size (:font-size data) + font-size (js/parseFloat (if (and (string? font-size) (pos? (alength font-size))) + font-size + (:font-size txt/default-typography)))] + (str (* font-size (cjk/ruby-font-scale (:ruby-size data))) "px"))) + +(defn generate-ruby-styles + [shape data] + (-> (generate-text-styles shape data) + (obj/set! "fontSize" (ruby-font-size data)) + (obj/set! "lineHeight" "1") + (obj/set! "textDecoration" "none") + ;; Base-span styles that belong to the base glyphs, not the reading. + (obj/unset! "textCombineUpright") + (obj/unset! "textEmphasis") + (obj/unset! "fontFeatureSettings") + (obj/unset! "display") + (obj/unset! "inlineSize"))) + +(defn- enum-or-default + "Value of the Japanese layout enum `attr` in `data`, or its default when + unset or unknown." + [data attr] + (let [value (get data attr)] + (if (cjk/valid-enum-value? attr value) value (cjk/enum-default attr)))) + +(defn generate-ruby-container-styles + [data] + #js {:rubyPosition (enum-or-default data :ruby-side) + :rubyAlign (enum-or-default data :ruby-align) + :rubyOverhang (enum-or-default data :ruby-overhang)}) diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index a2fbdd1ea5..b33c93fba1 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -9,17 +9,183 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] + [app.common.types.text.cjk-layout :as cjk] [app.config :as cf] [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.custom-stroke :refer [shape-custom-strokes]] [app.main.ui.shapes.fills :as fills] [app.main.ui.shapes.gradients :as grad] + [app.main.ui.shapes.text.styles :as sts] [app.util.object :as obj] [rumext.v2 :as mf])) (def fill-attrs [:fill-color :fill-color-gradient :fill-opacity]) +(defn- px + "CSS pixel size, or nil for a non-positive size." + [size] + (when (pos? size) (dm/str size "px"))) + +(defn- with-font-features + [style data] + (let [font-features (sts/font-feature-settings (:font-features data))] + (cond-> style + (some? font-features) (obj/set! "fontFeatureSettings" font-features)))) + +(defn- annotation-style + "Style of a ruby, warichu or emphasis line drawn at `font-size` along a + position-data strip." + [data font-size vertical? text-orientation fill] + (with-font-features + #js {:fontFamily (:font-family data) + :fontSize (px font-size) + :fontWeight (:font-weight data) + :fontStyle (:font-style data) + :direction (:direction data) + :writingMode (if vertical? "vertical-rl" "horizontal-tb") + :textSpacingTrim "normal" + :textOrientation text-orientation + :textAutospace "normal" + :whiteSpace "pre" + :fill fill} + data)) + +(defn- strip-text-combine-upright + "CSS text-combine-upright of a strip. SVG applies it to whole `text` + elements only, so the renderer gives each combined digit run of a + `digits` span its own strip, which takes `all`." + [data] + (let [value (:text-combine-upright data)] + (if-let [segments (cjk/digit-combine-segments (str (:text data)) value)] + (when (and (= 1 (count segments)) (second (first segments))) + "all") + (sts/css-text-combine-upright value)))) + +(defn- strip-text-props + "Props of the `text` element drawing a position-data strip's base text." + [shape data index fill browser-props] + (let [style (with-font-features + #js {:fontFamily (:font-family data) + :fontSize (:font-size data) + :fontWeight (:font-weight data) + :textTransform (:text-transform data) + :textDecoration (:text-decoration data) + :textCombineUpright (strip-text-combine-upright data) + :letterSpacing (:letter-spacing data) + :fontStyle (:font-style data) + :direction (:direction data) + :textSpacingTrim "normal" + :whiteSpace "pre"} + data) + key (dm/str "text-" (:id shape) "-" index)] + (if (= "vertical-rl" (:writing-mode data)) + ;; Vertical strip: glyphs run down the column; x is the column's + ;; central axis and y the strip top (stored y is the strip bottom). + #js {:key key + :x (+ (:x data) (/ (:width data) 2)) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style (-> style + (obj/set! "writingMode" "vertical-rl") + (obj/set! "textOrientation" (or (:text-orientation data) "mixed")) + (obj/set! "textAutospace" "normal") + (obj/set! "fill" fill))} + (cond-> #js {:key key + :x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data)) + :y (:y data) + :dominantBaseline "ideographic" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style (obj/set! style "fill" fill)} + (some? browser-props) + (obj/merge! browser-props))))) + +(defn- warichu-line-props + "Props of the `text` element drawing one warichu sub-line at half size in + its own strip: the renderer splits the span and places each sub-line." + [data fill] + (let [vertical? (= "vertical-rl" (:writing-mode data)) + font-size (* (js/parseFloat (:font-size data)) cjk/warichu-font-scale) + font-size (if (js/isNaN font-size) 0 font-size) + style (annotation-style data font-size vertical? (when vertical? "upright") fill) + top (- (:y data) (:height data))] + (if vertical? + #js {:x (+ (:x data) (/ (:width data) 2)) + :y top + :style style} + #js {:x (:x data) + :y top + :dominantBaseline "hanging" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style style}))) + +(defn- ruby-text-props + "Props of the `text` element drawing a strip's ruby annotation beside it + (vertical) or above/below it (horizontal), per its side and alignment. A + Western reading is centred as one word, never stretched to the base." + [shape data index ruby-font-size style] + (let [key (dm/str "ruby-" (:id shape) "-" index) + ruby-side (:ruby-side data (cjk/enum-default :ruby-side)) + word? (cjk/western-reading? (cjk/visible-ruby data))] + (if (= "vertical-rl" (:writing-mode data)) + (let [x (if (= "under" ruby-side) + (- (:x data) (/ ruby-font-size 2)) + (+ (:x data) (:width data) (/ ruby-font-size 2))) + top (- (:y data) (:height data))] + (if word? + #js {:key key + :x x + :y (+ top (/ (:height data) 2)) + :textAnchor "middle" + :style style} + #js {:key key + :x x + :y top + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style style})) + (let [rtl? (= "rtl" (:direction data)) + ruby-align (if word? "center" (:ruby-align data (cjk/enum-default :ruby-align))) + fit? (and (not word?) + (or (= "none" (:ruby-overhang data (cjk/enum-default :ruby-overhang))) + (= "space-around" ruby-align)))] + (cond-> #js {:key key + :x (if (= "center" ruby-align) + (+ (:x data) (/ (:width data) 2)) + (if rtl? (+ (:x data) (:width data)) (:x data))) + :y (if (= "under" ruby-side) + (:y data) + (- (:y data) (:height data))) + :dominantBaseline (if (= "under" ruby-side) "hanging" "text-after-edge") + :style style} + (= "center" ruby-align) + (obj/set! "textAnchor" "middle") + + (= "start" ruby-align) + (obj/set! "textAnchor" (if rtl? "end" "start")) + + fit? + (-> (obj/set! "textLength" (:width data)) + (obj/set! "lengthAdjust" "spacingAndGlyphs")) + + (= "space-between" ruby-align) + (-> (obj/set! "textLength" (:width data)) + (obj/set! "lengthAdjust" "spacing"))))))) + +(defn- emphasis-mark-props + "Props of the `text` element drawing one emphasis mark centred in its em + box, as placed by the renderer." + [data fill] + (let [size (:width data)] + #js {:x (+ (:x data) (/ size 2)) + :y (- (:y data) (/ (:height data) 2)) + :textAnchor "middle" + :dominantBaseline "central" + :style (annotation-style data size false nil fill)})) + (defn set-white-fill [shape] (let [update-color @@ -71,33 +237,24 @@ [:> :g group-props (for [[index data] (d/enumerate position-data)] - (let [rtl? (= "rtl" (:direction data)) + (let [vertical? (= "vertical-rl" (:writing-mode data)) + ruby (cjk/visible-ruby data) + font-size (js/parseFloat (:font-size data)) + ruby-font-size (if (js/isNaN font-size) + 0 + (* font-size (cjk/ruby-font-scale (:ruby-size data)))) + ;; Annotations take the fills of this strip's own render id. + annotation-fill (str "url(#fill-" index "-" render-id "-" index ")") browser-props - (cond - (cf/check-browser? :safari) + (when (and (not vertical?) (cf/check-browser? :safari)) #js {:dominantBaseline "hanging" :dy "0.2em" :y (- (:y data) (:height data))}) - props (-> #js {:key (dm/str "text-" (:id shape) "-" index) - :x (if rtl? (+ (:x data) (:width data)) (:x data)) - :y (:y data) - :dominantBaseline "ideographic" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style (-> #js {:fontFamily (:font-family data) - :fontSize (:font-size data) - :fontWeight (:font-weight data) - :textTransform (:text-transform data) - :textDecoration (:text-decoration data) - :letterSpacing (:letter-spacing data) - :fontStyle (:font-style data) - :direction (:direction data) - :whiteSpace "pre"} - (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))} - (cond-> browser-props - (obj/merge! browser-props))) + text-props (strip-text-props shape data index + (str "url(#fill-" index "-" render-id ")") + browser-props) shape (-> shape (assoc :fills (:fills data)) ;; The text elements have the shadow and blur already applied in the @@ -112,5 +269,17 @@ [:defs [:& fills/fills {:shape shape :render-id render-id}]] - [:& shape-custom-strokes {:shape shape :position index :render-id render-id} - [:> :text props (:text data)]]]))]])) + (if (:emphasis-mark data) + ;; Emphasis marks (圏点) are not stroked. + [:> :text (emphasis-mark-props data annotation-fill) (:text data)] + [:* + [:& shape-custom-strokes {:shape shape :position index :render-id render-id} + (if (= "warichu" (:warichu data)) + [:> :text (warichu-line-props data annotation-fill) (:text data)] + [:> :text text-props (:text data)])] + (when (some? ruby) + [:> :text (ruby-text-props shape data index ruby-font-size + (annotation-style data ruby-font-size vertical? + (:text-orientation data "mixed") + annotation-fill)) + ruby])])]))]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 3869f62a03..bd32ee5c17 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -29,6 +29,7 @@ [app.util.text-editor :as ted] [app.util.text-svg-position :as tsp] [app.util.text.content :as content] + [app.util.text.writing-mode :as wm] [promesa.core :as p] [rumext.v2 :as mf])) @@ -98,11 +99,11 @@ (st/emit! (dwt/clean-text-modifier id)))) ;; Always clear the task and log measurement errors. - (p/catch (fn [cause] - (log/error :hint "Could not measure text shape" - :shape-id id - :cause cause) - nil)))) + (p/merr (fn [cause] + (log/error :hint "Could not measure text shape" + :shape-id id + :cause cause) + (p/resolved nil))))) (defn- update-text-modifier [{:keys [grow-type id] :as shape} node] @@ -167,6 +168,16 @@ pending-update* (mf/use-state {}) pending-update (deref pending-update*) + ;; Vertical texts already measured as horizontal (see `stale-layout?`). + remeasured-ref (mf/use-ref #{}) + + ;; A vertical text keeps the position data the WASM renderer computed + ;; for its columns; measure it once more as horizontal text. + stale-layout? + (fn [id] + (and (not (contains? (mf/ref-val remeasured-ref) id)) + (wm/stale-vertical-layout? (:content (get text-shapes id))))) + text-change? (fn [id] (let [new-shape (get text-shapes id) @@ -176,7 +187,8 @@ (or (and (not remote?) ;; changes caused by a remote peer are not re-calculated (not (text-properties-equal? old-shape new-shape))) ;; When the position data is nil we force to recalculate - (nil? (:position-data new-shape))))) + (nil? (:position-data new-shape)) + (stale-layout? id)))) changed-texts (mf/with-memo [text-shapes pending-update] @@ -193,6 +205,7 @@ ;; Unique to indentify the pending state (let [uid (uuid/next) id (:id shape)] + (mf/set-ref-val! remeasured-ref (conj (mf/ref-val remeasured-ref) id)) (swap! pending-update* assoc uid id) ;; Callback refs run at the DOM commit boundary. Track the exact ;; measurement promise from that acknowledgement; any resize it diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs index 2534404394..8ea5eebfc5 100644 --- a/frontend/src/app/util/code_gen/style_css.cljs +++ b/frontend/src/app/util/code_gen/style_css.cljs @@ -15,6 +15,7 @@ [app.common.text :as legacy.txt] [app.common.types.shape.layout :as ctl] [app.common.types.text :as types.text] + [app.common.types.text.cjk-layout :as cjk] [app.main.ui.shapes.text.styles :as sts] [app.util.code-gen.common :as cgc] [app.util.code-gen.style-css-formats :refer [format-value format-shadow->css]] @@ -221,13 +222,19 @@ body { (shape->css-properties objects properties options) (format-css-properties options)))) +;; Custom properties generated code keeps; the others carry renderer state. +(def ^:private code-custom-properties + #{"--paragraph-line-height"}) + (defn format-js-styles [properties _options] (format-css-properties (->> (.keys js/Object properties) - (remove #(str/starts-with? % "--")) + (filter #(or (not (str/starts-with? % "--")) + (contains? code-custom-properties %))) (mapv (fn [key] - [(str/kebab key) (unchecked-get properties key)]))) + [(if (str/starts-with? key "--") key (str/kebab key)) + (unchecked-get properties key)]))) nil)) (defn node->css @@ -249,6 +256,25 @@ body { (dm/str shape-selector " ." (:$id node)) (format-js-styles properties nil)))) +(defn- ruby-css + "Rules for the ruby wrapper and annotation of a text node with a visible + reading, or nil." + [shape shape-selector node] + (when (and (types.text/is-text-node? node) (cjk/visible-ruby node)) + (dm/fmt + ".% {\n%\n}\n.% {\n%\n}" + (dm/str shape-selector " ." (:$id node) "-ruby") + (format-js-styles (sts/generate-ruby-container-styles node) nil) + (dm/str shape-selector " ." (:$id node) "-rt") + (format-js-styles (sts/generate-ruby-styles shape node) nil)))) + +(defn- tcy-css + "Rule for the combined digit runs of `digits` tate-chu-yoko spans, or nil." + [shape shape-selector] + (when (some #(cjk/digit-combine? (:text-combine-upright %)) + (types.text/node-seq types.text/is-text-node? (:content shape))) + (dm/fmt ".% .tcy {\n%\n}" shape-selector (format-js-styles sts/tcy-run-style nil)))) + (defn generate-text-css [shape] (let [selector (cgc/shape->selector shape)] @@ -256,7 +282,11 @@ body { :content (legacy.txt/index-content) (types.text/node-seq) - (map #(node->css shape selector %)) + (mapcat (fn [node] + [(node->css shape selector node) + (ruby-css shape selector node)])) + (cons (tcy-css shape selector)) + (remove nil?) (str/join "\n")))) (defn get-shape-css-selector diff --git a/frontend/src/app/util/text/writing_mode.cljs b/frontend/src/app/util/text/writing_mode.cljs new file mode 100644 index 0000000000..8c74b182c5 --- /dev/null +++ b/frontend/src/app/util/text/writing_mode.cljs @@ -0,0 +1,55 @@ +;; 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 app.util.text.writing-mode + "Writing mode of text content as the active renderer lays it out. Only the + WASM renderer supports vertical writing; the SVG renderer lays every text + out horizontally and leaves the stored attrs as they are." + (:require + [app.common.types.shape :as cts] + [app.common.types.text :as txt] + [app.common.types.text.cjk-layout :as cjk])) + +(defn vertical-layout-active? + "True when the active renderer lays out vertical writing (WASM)." + [] + cts/wasm-enabled?) + +(defn content-writing-mode + "Effective writing mode of a text content (see `cjk/content-writing-mode`)." + [content] + (when ^boolean (vertical-layout-active?) + (cjk/content-writing-mode content))) + +(defn vertical-text-content? + "True when the text content flows vertically under the active renderer." + [content] + (and ^boolean (vertical-layout-active?) + (cjk/vertical-text-content? content))) + +(defn stale-vertical-layout? + "True when the content is stored as vertical but the active renderer lays + it out horizontally, so a WASM-computed layout (such as `:position-data`) + does not match." + [content] + (and (not ^boolean (vertical-layout-active?)) + (cjk/vertical-text-content? content))) + +(defn renderable-paragraph + "Paragraph attrs as the active renderer lays them out: without the vertical + writing attrs when the renderer lays every text out horizontally." + [paragraph] + (if ^boolean (vertical-layout-active?) + paragraph + (apply dissoc paragraph cjk/whole-shape-paragraph-attrs))) + +(defn renderable-content + "Text content as the active renderer lays it out (see + `renderable-paragraph`)." + [content] + (if ^boolean (vertical-layout-active?) + content + (txt/transform-nodes txt/is-paragraph-node? renderable-paragraph content))) diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index fb10dfef85..acdf7bb323 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -10,14 +10,17 @@ Each test guards against a concrete bug found in the CSS/HTML generation of layout children and text shapes." (:require + ["react-dom/server" :as rds] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.uuid :as uuid] + [app.main.ui.shapes.text.fo-text :as fo-text] [app.util.code-gen.markup-html :as html] [app.util.code-gen.style-css :as css] [cljs.test :refer [deftest is testing] :include-macros true] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [rumext.v2 :as mf])) ;; --- Builders ------------------------------------------------------------ @@ -180,6 +183,29 @@ :children [{:text "Hello" :fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]}) +(def ^:private ruby-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :children [{:text "漢字" + :ruby "かんじ" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(defn- text-shape + [content] + (let [tid (uuid/next)] + {:id tid :name "Text" :type :text + :parent-id uuid/zero :frame-id uuid/zero + :x 0 :y 0 :width 100 :height 40 + :selrect (grc/make-rect 0 0 100 40) + :points (pts 0 0 100 40) + :grow-type :fixed + :content content})) + (deftest text-markup-emits-node-id-classes (testing "generated text markup carries the $id classes the CSS rules target" (let [tid (uuid/next) @@ -194,3 +220,161 @@ (is (str/includes? markup "root-0") "the text nodes must expose their $id as a class for the CSS to apply") (is (str/includes? markup "root-0-paragraph-set-0-paragraph-0"))))) + +(def ^:private warichu-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :children [{:text "割注入り" + :warichu "warichu" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(def ^:private palt-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "かな" + :font-features "palt" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(deftest foreign-object-text-emits-warichu-styles + (testing "browser/foreignObject render folds a warichu span into two half-size lines" + (let [text (text-shape warichu-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "割注入り")) + (is (str/includes? markup "display:inline-block")) + (is (str/includes? markup "font-size:10px")) + (is (str/includes? markup "inline-size:2em"))))) + +(deftest foreign-object-warichu-sizes-by-unicode-code-points + (testing "non-BMP characters count as one slot when sizing warichu lines" + (let [content (assoc-in warichu-text-content + [:children 0 :children 0 :children 0 :text] + "割𠀀注😀") + text (text-shape content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "割𠀀注😀")) + (is (str/includes? markup "inline-size:2em"))))) + +(def ^:private tcy-digits2-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :children [{:text "平成31年" + :text-combine-upright "digits2" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(deftest foreign-object-text-combines-each-digit-run + (testing "browsers lack CSS `digits `, so each digit run gets its own `all` span" + (let [text (text-shape tcy-digits2-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (re-find #"]*style=\"text-combine-upright:all\">31" markup)) + (is (not (str/includes? markup "digits")))))) + +(deftest generated-code-combines-each-digit-run + (testing "generated markup marks digit runs and the CSS combines them" + (let [text (text-shape tcy-digits2-text-content) + markup (html/generate-markup (objects text) [text]) + css (css/generate-text-css text)] + (is (re-find #"31" markup)) + (is (re-find #"\.tcy \{\s*text-combine-upright: all" css)) + (is (not (str/includes? css "digits")))))) + +(deftest generated-css-keeps-the-writing-mode + (testing "code keeps vertical writing whatever renderer is active" + (let [css (css/generate-text-css (text-shape ruby-text-content))] + (is (str/includes? css "writing-mode: vertical-rl")) + (is (str/includes? css "text-orientation: upright"))))) + +(deftest ruby-text-drops-base-only-styles + (testing "the reading does not inherit emphasis marks or font features" + (let [content (update-in ruby-text-content [:children 0 :children 0 :children 0] + assoc :text-emphasis "filled-dot" :font-features "palt") + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape (text-shape content) :grow-type :fixed})) + rt (re-find #"]*>" markup)] + (is (not (str/includes? rt "text-emphasis"))) + (is (not (str/includes? rt "font-feature-settings")))))) + +(deftest foreign-object-text-emits-font-feature-settings + (testing "browser/foreignObject render emits palt/vpal as OpenType features" + (let [text (text-shape palt-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "font-feature-settings:"palt""))))) + +(deftest text-markup-emits-ruby-annotations + (testing "generated text markup keeps ruby annotations beside the base text" + (let [text (text-shape ruby-text-content) + markup (html/generate-markup (objects text) [text])] + (is (str/includes? markup "]*>" markup)] + (is (str/includes? rt "font-size:5px"))))) + +(deftest generated-css-styles-ruby-annotations + (testing "generated CSS has rules for the ruby wrapper and its annotation" + (let [text (text-shape ruby-text-content) + markup (html/generate-markup (objects text) [text]) + css (css/generate-text-css text) + ruby-class (second (re-find #"]*class=\"[^\"]*?([^\" ]+-ruby)\"" markup)) + rt-class (second (re-find #"]*class=\"([^\"]+)\"" markup)) + rule (fn [class] + (second (re-find (re-pattern (str "\\." class " \\{([^}]*)\\}")) css)))] + (is (some? ruby-class)) + (is (some? rt-class)) + (is (re-find #"ruby-position:\s*over" (or (rule ruby-class) ""))) + (is (re-find #"font-size:\s*10px" (or (rule rt-class) "")))))) + +(deftest generated-css-styles-ruby-only-on-text-nodes + (testing "a paragraph that stores the reading as a default gets no ruby rules" + (let [content (assoc-in ruby-text-content + [:children 0 :children 0 :ruby] + "かんじ") + css (css/generate-text-css (text-shape content))] + (is (not (str/includes? css "paragraph-0-ruby"))) + (is (not (str/includes? css "paragraph-0-rt")))))) + +(deftest generated-css-defines-the-paragraph-line-height-for-annotation-room + (testing "auto clearance builds on the paragraph line height, so code keeps the variable" + (let [content (-> ruby-text-content + (assoc-in [:children 0 :children 0 :line-height] "2") + (assoc-in [:children 0 :children 0 :children 0 :annotation-clearance] "auto")) + css (css/generate-text-css (text-shape content))] + (is (re-find #"--paragraph-line-height:\s*2" css)) + (is (re-find #"line-height:\s*calc\(var\(--paragraph-line-height" css)) + (is (not (str/includes? css "--fills"))) + (is (not (str/includes? css "--font-id")))))) diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs index c404a86fcd..81d59b673c 100644 --- a/frontend/test/frontend_tests/copy_as_svg_test.cljs +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -16,6 +16,7 @@ [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.shape :as cts] [app.util.code-gen.markup-svg :as svg] [cljs.test :refer [deftest is testing] :include-macros true])) @@ -37,6 +38,45 @@ [re s] (count (re-seq re s))) +(def ^:private strip-defaults + {:fills [{:fill-color "#112233" :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400"}) + +(defn- setup-text + "A text shape with the given position-data `strips` (over + `strip-defaults`), with its objects and selection." + [{:keys [width height]} & strips] + (let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height}) + (assoc :name "Text" + :position-data (mapv #(merge strip-defaults %) strips))) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :text + shape) + objects (:objects (cthf/current-page file))] + {:objects objects + :shapes [(get objects (cthi/id :text))]})) + +(defn- vertical-strip + [attrs] + (merge {:x 20 :y 100 :width 24 :height 80 + :writing-mode "vertical-rl" + :text-orientation "upright"} + attrs)) + +(defn- horizontal-strip + [attrs] + (merge {:x 20 :y 60 :width 100 :height 20 + :writing-mode "horizontal-tb"} + attrs)) + +(defn- emphasis-mark + "A mark entry as the renderer emits it: the mark glyph in its em box." + [x y] + {:x x :y y :width 10 :height 10 :font-size "10px" :emphasis-mark true :text "•"}) + (deftest empty-selection-yields-empty-string (is (= "" (svg/generate-markup {} [])))) @@ -59,3 +99,97 @@ "multi-select must NOT emit multiple roots") (is (= 1 (count-matches #"" markup)) "multi-select must NOT emit multiple closing tags")))) + +(deftest vertical-text-svg-preserves-browser-layout-properties + (testing "Static SVG carries the vertical writing properties used by browser exports" + (let [{:keys [objects shapes]} (setup-text {:width 40 :height 100} + (vertical-strip {:font-features "vpal" :text "うA"})) + markup (svg/generate-markup objects shapes)] + (is (re-find #"writing-mode:vertical-rl" markup)) + (is (re-find #"text-orientation:upright" markup)) + (is (re-find #"text-autospace:normal" markup)) + (is (re-find #"font-feature-settings:"vpal"" markup)) + (is (not (re-find #"•<" markup)) "one text element per mark") + (is (re-find #"x=\"49\"" markup) "marks centre on their box") + (is (re-find #"text-anchor=\"middle\"" markup)) + (is (re-find #"font-size:10px" markup)) + (is (re-find #"fill:url\(#fill-1-[^)]+-1\)" markup) + "a mark references its generated per-strip fill") + (is (not (re-find #"]*>割注<" markup) "first sub-line on the right half") + (is (re-find #"x=\"25\"[^>]*>入り<" markup) "second sub-line on the left half") + (is (= 2 (count-matches #"font-size:10px" markup))) + (is (not (re-find #"]*>editor<" markup)] + (is (= 2 (count rubies))) + (is (every? #(re-find #"text-anchor=.?middle" %) rubies)) + (is (not-any? #(re-find #"textLength|text-length" %) rubies)) + (is (re-find #"text-orientation:mixed[^>]*>editor<" markup))))) + +(deftest vertical-digit-composite-svg-combines-its-own-strip + (testing "SVG applies text-combine-upright per text element, so each digit-run strip combines" + (let [{:keys [objects shapes]} (setup-text {:width 40 :height 120} + (vertical-strip {:y 40 :height 40 :text "平成" + :text-combine-upright "digits2"}) + (vertical-strip {:y 60 :height 20 :text "31" + :text-combine-upright "digits2"}) + (vertical-strip {:y 80 :height 20 :text "年" + :text-combine-upright "digits2"})) + markup (svg/generate-markup objects shapes)] + (is (= 1 (count-matches #"text-combine-upright:all" markup)) + "only the 31 strip combines") + (is (re-find #"text-combine-upright:all[^>]*>31<" markup)) + (is (not (re-find #"digits" markup))))))