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))))))