penpot/render-wasm/src/shapes/text_policy.rs
alonso.torres 8dbdb00fde ✨ Add a CJK text layout proof of concept
Choose paragraph layout rules from an optional language tag. Pass language
to horizontal and vertical shaping, add basic Chinese punctuation rules,
and add experimental Korean spaces-only wrapping.

Keep text boundaries grapheme-safe and preserve paragraph profiles across
editor splits and merges. Add reproducible font fixtures for localized
glyphs and regression tests for the shared WASM protocol and layout paths.

Validation: renderer, common JVM/JS, frontend and exporter tests pass.
Both WASM targets build. Full CI still reports baseline lint and format
errors and one backend multipart-upload test failure.

AI-assisted-by: GPT-6
2026-11-12 00:47:52 +01:00

86 lines
3.0 KiB
Rust

//! Paragraph typography policies. Geometry and source offsets are shared;
//! Japanese spacing remains in the Japanese layout passes.
use super::japanese::break_allowed_between;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum TextLayoutPolicy {
#[default]
Plain,
Japanese,
Chinese,
KoreanSyllable,
/// POC only: suppress every non-space boundary, including punctuation.
KoreanSpacesOnly,
}
impl TextLayoutPolicy {
pub fn for_language(language: &str, korean_spaces_only: bool) -> Self {
let primary = language.split('-').next().unwrap_or_default();
if primary.eq_ignore_ascii_case("ja") {
Self::Japanese
} else if primary.eq_ignore_ascii_case("zh") {
Self::Chinese
} else if primary.eq_ignore_ascii_case("ko") {
if korean_spaces_only {
Self::KoreanSpacesOnly
} else {
Self::KoreanSyllable
}
} else {
Self::Plain
}
}
/// Legal boundary between graphemes, before fitting a line or column.
pub fn break_allowed(self, before: char, after: char) -> bool {
match self {
Self::Japanese => break_allowed_between(before, after),
Self::KoreanSpacesOnly => before.is_whitespace() || after.is_whitespace(),
Self::Chinese | Self::KoreanSyllable => {
!"([{〈《「『【〔〖〘〚‘“([{".contains(before)
&& !",。、.!?:;)]}〉》」』】〕〗〙〛’”)]},.!?:;".contains(after)
&& !(before == after && matches!(after, '…' | '—'))
&& !(before.is_ascii_alphanumeric() && after.is_ascii_alphanumeric())
}
Self::Plain => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_selects_one_policy_without_script_detection() {
for (language, expected) in [
("", TextLayoutPolicy::Plain),
("en", TextLayoutPolicy::Plain),
("ja-JP", TextLayoutPolicy::Japanese),
("ZH-Hant-TW", TextLayoutPolicy::Chinese),
("ko", TextLayoutPolicy::KoreanSyllable),
] {
assert_eq!(TextLayoutPolicy::for_language(language, false), expected);
}
assert_eq!(
TextLayoutPolicy::for_language("ko-KR", true),
TextLayoutPolicy::KoreanSpacesOnly
);
assert_eq!(
TextLayoutPolicy::for_language("zh", true),
TextLayoutPolicy::Chinese
);
}
#[test]
fn chinese_basic_restrictions_do_not_include_small_kana() {
let policy = TextLayoutPolicy::Chinese;
assert!(!policy.break_allowed('「', '中'));
assert!(!policy.break_allowed('文', '」'));
assert!(policy.break_allowed('中', '文'));
assert!(policy.break_allowed('あ', 'っ'));
assert!(!TextLayoutPolicy::Japanese.break_allowed('あ', 'っ'));
}
}