diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc index 8f9aa6dc60..fac1eb5d93 100644 --- a/common/src/app/common/types/text/cjk_layout.cljc +++ b/common/src/app/common/types/text/cjk_layout.cljc @@ -184,14 +184,15 @@ (defn digit-combine-segments "Text split for a `digits` tate-chu-yoko value: `[text combine?]` pairs, - where runs of two up to the value's maximum ASCII or full-width digits - combine and longer runs stay ordinary text. Nil for other values." + where runs of up to the value's maximum ASCII or full-width digits + combine, a lone digit included as in CSS `digits`, and longer runs stay + ordinary text. Nil for other values." [text value] (when-let [max-len (get digit-combine-max-lengths value)] (->> (partition-by digit? (seq text)) (mapv (fn [chars] (let [run (apply str chars)] - [run (and (digit? (first chars)) (<= 2 (count chars) max-len))])))))) + [run (and (digit? (first chars)) (<= (count chars) max-len))])))))) (defn warichu-text? "True when a text node renders as warichu, which needs at least two diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index 5d023980b5..4484983a40 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -439,7 +439,9 @@ (t/is (= [["第" false] ["123" true]] (cjk/digit-combine-segments "第123" "digits3"))) (t/is (= [["12345" false]] (cjk/digit-combine-segments "12345" "digits"))) - (t/is (= [["1" false]] (cjk/digit-combine-segments "1" "digits"))) + (t/is (= [["1" true]] (cjk/digit-combine-segments "1" "digits"))) + (t/is (= [["2" true] ["月" false] ["5" true] ["日" false]] + (cjk/digit-combine-segments "2月5日" "digits2"))) (t/is (nil? (cjk/digit-combine-segments "31" "all")))) (t/deftest western-reading-test diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 3315f9edd6..fc396c7d7c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -23,7 +23,7 @@ - **plugin-types:** Added `ruby` property for text shapes (furigana annotation) - **plugin-types:** Added `textEmphasis` property for text shapes (emphasis marks / bouten) - **plugin-types:** Added `warichu` property for text shapes (inline two-line notes) -- **plugin-types:** `textCombineUpright` now accepts `digits` (combine 2-4 digit runs) +- **plugin-types:** `textCombineUpright` now accepts `digits` (combine runs of up to 4 digits, a lone digit included) - **plugin-types:** Added `fontFeatures` (`none`, `palt`, or `vpal`) to text shapes and text ranges. - **plugin-types:** Added `annotationClearance` (`none` or `auto`) to text shapes. - **plugin-types:** Added `lineAdjustment` (`push-in-first`, `push-out-first` or `push-out-only`) to text shapes: how Japanese lines slightly too long are fitted. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 95ac3b99df..39a3ff48ba 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4727,9 +4727,10 @@ export interface Text extends ShapeBase { /** * Tate-chu-yoko: sets text upright as one block in vertical writing. `all` - * combines the whole span; `digits` combines runs of 2-4 digits - * (`digits2`/`digits3` cap the run at 2/3); `none` (the default) turns it - * off. Returns 'mixed' if text spans use different values. + * combines the whole span; `digits` combines runs of up to 4 digits, so a + * lone digit stands upright (`digits2`/`digits3` cap the run at 2/3); + * `none` (the default) turns it off. Returns 'mixed' if text spans use + * different values. */ textCombineUpright: 'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed'; diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index e9e1d3186e..37cfe2ea22 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -263,11 +263,12 @@ pub enum TextCombineUpright { #[default] None, All, - /// Runs of 2-4 ASCII or full-width digits combine into one upright cell. + /// Runs of up to 4 ASCII or full-width digits combine into one upright + /// cell, so a lone digit stands upright (CSS `digits`). Digits, - /// Like `Digits`, for runs of exactly 2 (CSS `digits 2`). + /// Like `Digits`, for runs of up to 2 (CSS `digits 2`). Digits2, - /// Like `Digits`, for runs of 2-3. + /// Like `Digits`, for runs of up to 3. Digits3, } diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index 38f4d8dc30..eb0c963749 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -57,8 +57,8 @@ fn is_tcy_digit(c: char) -> bool { } /// Split a TCY `digits` span into pieces. Maximal ASCII or full-width digit -/// runs of 2..=max chars are marked tcy; the rest keep normal vertical -/// layout. Returns (piece text, span-relative UTF-16 start, tcy). +/// runs of 1..=max chars are marked tcy, as in CSS `digits`, so a lone digit +/// stands upright too; the rest keep normal vertical layout. Returns (piece text, span-relative UTF-16 start, tcy). fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { let mut pieces: Vec<(String, usize, bool)> = Vec::new(); let mut utf16 = 0usize; @@ -71,7 +71,7 @@ fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { utf16 += c.len_utf16(); } for (piece, _, tcy) in pieces.iter_mut() { - *tcy = *tcy && (2..=max).contains(&piece.chars().count()); + *tcy = *tcy && (1..=max).contains(&piece.chars().count()); } pieces } @@ -219,7 +219,7 @@ impl<'a> SpanCells<'a> { { return; } - // `digits` merges each run of 2..=max digits into one upright cell. + // `digits` merges each run of 1..=max digits into one upright cell. let pieces = match combine.digits_max() { Some(max) => split_digit_runs(text, max), None => vec![(text.to_string(), 0, false)], @@ -280,18 +280,14 @@ impl<'a> SpanCells<'a> { } let letter_spacing = self.span.letter_spacing; - let mut shaped = self.shape(piece, self.span.font_size, false); + let shaped = self.shape(piece, self.span.font_size, false); if shaped.is_empty() { return false; } let em = self.span.font_size.max(1.0); - let mut combined_advance = 0.0f32; - for run in &mut shaped { - run.advance = run.spread_glyphs(letter_spacing); - combined_advance += run.advance; - } - // Letter-spacing also separates adjacent fallback runs. - combined_advance += letter_spacing * (shaped.len() - 1) as f32; + // As in CSS, the composite ignores letter-spacing inside it: spacing + // only separates it from its neighbours down the column (`extent`). + let combined_advance: f32 = shaped.iter().map(|run| run.advance).sum(); // Only a composite wider than the em is squeezed, and only across: // glyphs keep their full height, as in JLREQ and CSS. let h_scale = (em / combined_advance.max(1.0)).min(1.0); @@ -972,7 +968,7 @@ mod tests { #[test] fn split_digit_runs_honours_the_max_parameter() { - // max 2: only exactly-two-digit runs combine. + // max 2: runs of up to two digits combine. let pieces = split_digit_runs("平成31年123日", 2); let flags: Vec<(&str, bool)> = pieces .iter() @@ -994,7 +990,7 @@ mod tests { } #[test] - fn split_digit_runs_marks_two_to_four_digit_runs() { + fn split_digit_runs_marks_runs_of_up_to_four_digits() { let pieces = split_digit_runs("平成31年12345日5", 4); let flags: Vec<(&str, usize, bool)> = pieces .iter() @@ -1008,7 +1004,7 @@ mod tests { ("年", 4, false), ("12345", 5, false), ("日", 10, false), - ("5", 11, false), + ("5", 11, true), ] ); } @@ -1045,6 +1041,21 @@ mod tests { assert_eq!(layout.cells.len(), 3); } + #[test] + fn tate_chu_yoko_digits_sets_a_lone_digit_upright() { + let mut content = make_content_with_spans(&["2月5日"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy: Vec<(usize, usize)> = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) + .map(|c| (c.start, c.end)) + .collect(); + assert_eq!(tcy, vec![(0, 1), (2, 3)]); + } + #[test] fn tate_chu_yoko_digits_combines_four_ascii_digits() { let mut content = make_content_with_spans(&["2025年"], 400.0); @@ -1134,6 +1145,43 @@ mod tests { ); } + #[test] + fn letter_spacing_stays_outside_a_tate_chu_yoko_composite() { + // CSS combines the text ignoring letter-spacing. Counting it in the + // composite width set the digits off centre, left of the kanji. + let tcy_cell = |letter_spacing: f32| { + let mut content = spaced_content("く12く", letter_spacing); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_content(&content, 1000.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) + .cloned() + .expect("the digit run combines"); + let CellKind::TateChuYoko { run_start, .. } = cell.kind else { + unreachable!() + }; + let positions = layout.runs[run_start].positions.clone(); + (cell, positions) + }; + let (plain, plain_positions) = tcy_cell(0.0); + let (spaced, spaced_positions) = tcy_cell(5.0); + assert!( + (spaced.h_advance - plain.h_advance).abs() < 0.01, + "the composite width ignores letter-spacing" + ); + assert_eq!( + plain_positions, spaced_positions, + "glyphs keep their places" + ); + assert!( + (spaced.extent - plain.extent - 5.0).abs() < 0.01, + "letter-spacing still follows the composite down the column" + ); + } + // A tiny Noto Sans JP subset with `vmtx`/`vhea`: U+3031 (〱, vertical kana // repeat mark) advances 2em vertically and 1em horizontally; // U+3042/U+304F are symmetric controls. diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index db3abf82ec..2981b52130 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -416,7 +416,15 @@ pub(super) fn apply_policy_spacing(cells: &mut [FlowCell], text: &str, policy: T - 0.5 * cells[i - 1].cell.font_size) .max(0.0); let compression = (policy.compression_em(before, after, true) * em).min(available); - let gap = policy.script_spacing_em(before, after) * em; + // A tate-chu-yoko composite sets in one ideograph's cell, so the + // Han/Western gap of its digits or letters does not apply. + let tate_chu_yoko = + |flow: &FlowCell| matches!(flow.cell.kind, CellKind::TateChuYoko { .. }); + let gap = if tate_chu_yoko(&cells[i - 1]) || tate_chu_yoko(&cells[i]) { + 0.0 + } else { + policy.script_spacing_em(before, after) * em + }; cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression) .max(cells[i - 1].trailing_spacing.max(0.0)); cells[i - 1].cell.minimum_oikomi_extent = cells[i - 1].cell.extent; diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 26cb87274e..1599a78c41 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -828,7 +828,7 @@ pub fn layout_for_rect(text_content: &TextContent, rect: &Rect) -> VerticalLayou mod tests { use super::super::test_support::*; use super::*; - use crate::shapes::TextOrientation; + use crate::shapes::{TextCombineUpright, TextOrientation, TextSpan}; fn cjk_layout( texts: &[&str], @@ -1046,6 +1046,35 @@ mod tests { .all(|cell| cell.minimum_oikomi_extent <= cell.extent)); } + #[test] + fn regional_chinese_spacing_skips_tate_chu_yoko_but_spaces_sideways_latin() { + let tops = |language: &str, text: &str, combine: TextCombineUpright| { + let span = TextSpan { + text_combine_upright: combine, + ..make_span(text) + }; + let mut paragraph = vertical_paragraph(vec![span], TextAlign::Left, 1.0); + paragraph.set_language(language.to_string()); + let content = content_of(vec![paragraph], EM * 10.0, GrowType::Fixed); + let layout = layout_with_height( + &provider(include_bytes!("../../fonts/cjk-locl-test.ttf")), + &content, + EM * 10.0, + ); + layout.cells.iter().map(|cell| cell.top).collect::>() + }; + // Upright digits take one ideograph's cell: no Han/Western gap. + assert_eq!( + tops("zh-Hans-CN", "文12文", TextCombineUpright::Digits), + tops("", "文12文", TextCombineUpright::Digits) + ); + // A sideways Latin run keeps its quarter-em gap. + assert_ne!( + tops("zh-Hans-CN", "文AB文", TextCombineUpright::None), + tops("", "文AB文", TextCombineUpright::None) + ); + } + #[test] fn korean_word_boundaries_cross_styles_and_graphemes_stay_together() { let mut paragraph = vertical_paragraph( diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index 2d67c83175..deeaa54b7d 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -8,7 +8,7 @@ use crate::math::Rect; use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign}; use super::annotations::{emphasis_mark_center, EmphasisMark, RubyCell}; -use super::font_tables::upright_baseline; +use super::font_tables::{upright_baseline, upright_centre_metrics}; use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; use super::shaping::{single_glyph_blob, ShapedRun}; @@ -174,10 +174,11 @@ fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) } => { let composite = &layout.runs[run_start..run_start + run_count]; let combined_advance: f32 = composite.iter().map(|run| run.advance).sum(); - // Vertical centring uses the tallest run's ascent/descent. + // Vertical centring uses the tallest run's em box, the metrics + // upright glyphs use, so the composite shares their baseline. let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| { - let (_, metrics) = run.font.metrics(); - (a.min(metrics.ascent), d.max(metrics.descent)) + let (ascent, descent) = upright_centre_metrics(&run.font); + (a.min(ascent), d.max(descent)) }); let x0 = x_center - (combined_advance * h_scale) / 2.0; let baseline = y_top + cell.font_size / 2.0 - (ascent + descent) / 2.0; @@ -739,7 +740,7 @@ fn decoration_bar( mod tests { use super::super::test_support::*; use super::*; - use crate::shapes::{StrokeStyle, TextEmphasis, TextOrientation, TextSpan}; + use crate::shapes::{StrokeStyle, TextCombineUpright, TextEmphasis, TextOrientation, TextSpan}; #[test] fn glyph_draws_are_built_once_and_follow_the_origin() { @@ -934,6 +935,32 @@ mod tests { } } + #[test] + fn tate_chu_yoko_digits_share_the_upright_glyph_baseline() { + // The hhea ascent of a CJK face exceeds the em; centring the + // composite on it set digits about 0.07em below the kanji beside them. + let mut content = make_content_with_spans(&["く12く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let cell = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("the digit run combines"); + let CellKind::TateChuYoko { run_start, .. } = cell.kind else { + unreachable!() + }; + let draws = cell_draws(&layout, cell, (0.0, 0.0)); + let transform = draws[0].transform.expect("a composite is transformed"); + let expected = cell.top + upright_baseline(&layout.runs[run_start].font, cell.font_size); + assert!( + (transform.translate_y() - expected).abs() < 0.01, + "baseline {} instead of {expected}", + transform.translate_y() + ); + } + #[test] fn single_paint_passes_cover_ruby_like_the_fill_pass() { let content = spans_content(