mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Set digits tate-chu-yoko upright and centred in vertical text
Four fixes to how `digits` tate-chu-yoko composites lay out in vertical text: - A lone digit combines too, as CSS `digits <n>` combines runs of n or fewer, so it stands upright instead of turning sideways. The renderer (`split_digit_runs`) and the export path (`digit-combine-segments`) share the rule. - Composites centre on the OS/2 typographic metrics, like upright glyphs. The hhea ascent of CJK faces exceeds the em, which set digits about 0.07em below the kanji beside them. - Letter-spacing stays outside the composite, as in CSS. Counting the trailing spacing in its width moved the digits left by half of it. - The regional Chinese profiles no longer add the Han/Western gap around a composite: it sets in one ideograph's cell, as JLREQ already does. AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
996f498e9b
commit
b053ac8751
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
7
plugins/libs/plugin-types/index.d.ts
vendored
7
plugins/libs/plugin-types/index.d.ts
vendored
@ -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';
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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::<Vec<_>>()
|
||||
};
|
||||
// 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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user