CJK 1.4: Korean breaks at spaces

Korean is set word by word (KLREQ): Hangul, in any language, and Han
ideographs in Korean-language text take no line breaks between
characters, so lines break at spaces. Glyphs carry their language tag
for this. A paragraph attribute, koreanCharBreaks (type.para, paragraph
styles, Paragraph Style Options > Justification), breaks between
syllables instead. It is DesignCraft-only; IDML doesn't carry it.
A paragraph's kinsoku set decides which breaks it forbids, and Korean
still breaks at spaces under one.

Kinsoku still holds, also when the single-line composer has to split a
word wider than the measure: the split moves back so no line starts
with closing punctuation.
This commit is contained in:
aiongg 2026-10-07 09:05:29 -04:00
parent 832939691c
commit e0fb78a230
11 changed files with 193 additions and 10 deletions

View File

@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).

View File

@ -48,6 +48,8 @@ pub struct Spacing {
pub hyph_zone: f64,
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
pub optical: bool,
/// Korean text breaks between syllables instead of at spaces.
pub korean_char_breaks: bool,
}
impl Default for Spacing {
@ -68,6 +70,7 @@ impl Default for Spacing {
ragged_stretch: 24.0,
hyph_zone: 0.0,
optical: false,
korean_char_breaks: false,
}
}
}
@ -168,15 +171,41 @@ const INF: f64 = 10000.0;
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
/// kana or the prolonged sound mark, and none ends with opening brackets.
pub fn cjk_break_between(a: char, b: char) -> bool {
let cjk = |c: char| crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F);
if !(cjk(a) || cjk(b)) {
return false;
}
(is_cjk(a) || is_cjk(b)) && kinsoku_allows(a, b)
}
fn is_cjk(c: char) -> bool {
crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F)
}
fn kinsoku_allows(a: char, b: char) -> bool {
const NO_START: &str = "、。,.・:;?!ー)」』】〕〉》}]〙〗ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ々〻ゝゞヽヾ!),.:;?]}";
const NO_END: &str = "(「『【〔〈《{[〘〖([{";
!NO_START.contains(b) && !NO_END.contains(a)
}
/// Hangul, and Han ideographs (hanja) in Korean text: Korean is set word by word (KLREQ), so
/// these letters break like Western ones, at spaces.
fn korean_letter(g: &Glyph) -> bool {
let c = g.ch as u32;
let hangul = matches!(c, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF);
let han = matches!(c, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF);
hangul || (han && g.lang == Some("ko"))
}
/// Is the place between glyphs `a` and `b` one where CJK text may break (next to an ideograph, kana
/// or hangul)? Korean letters take no breaks between characters unless the paragraph asks for
/// character-based breaking.
pub(crate) fn cjk_pair(a: &Glyph, b: &Glyph, korean_char_breaks: bool) -> bool {
let cjk = |g: &Glyph| is_cjk(g.ch) && (korean_char_breaks || !korean_letter(g));
cjk(a) || cjk(b)
}
/// [`cjk_break_between`] for glyphs `a` and `b` (see [`cjk_pair`]).
fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
cjk_pair(a, b, sp.korean_char_breaks) && kinsoku_allows(a.ch, b.ch)
}
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK)
@ -310,7 +339,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
let mut p = Item::penalty(0.0, 50.0, true);
p.hang = right_hang(g, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch)) {
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) {
let mut p = Item::penalty(0.0, 0.0, false);
p.hang = right_hang(g, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
@ -590,6 +619,14 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
out
}
/// Where a line with no break opportunity before glyph `i` (a word wider than the measure) is
/// split: before `i`, or earlier where kinsoku forbids that (no line starts with closing
/// punctuation).
fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if kinsoku_allows(a.ch, b.ch));
(start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i)
}
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
let n = glyphs.len();
@ -638,7 +675,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
(last_space.map_or(i, |s| s.0), false, false)
}
Some((p, h)) => (p, h, false),
None => (i, false, false),
None => (emergency_split(glyphs, start, i), false, false),
});
break;
}
@ -648,7 +685,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
}
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|| g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch))
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
{
last_ok = Some((i + 1, false));
} else if hyph_after[i] && may_hyphenate(hyphens) {

View File

@ -1128,6 +1128,7 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
ragged_stretch: base_size * 2.0,
hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
korean_char_breaks: pp.korean_char_breaks,
}
}
@ -1146,7 +1147,8 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
if let Some(next) = right.first() {
let a = g.ch;
let b = next.ch;
let cjk = upright_in_vertical(a) || upright_in_vertical(b);
// A kinsoku set rules where CJK text may break; Korean still breaks at spaces.
let cjk = breaker::cjk_pair(g, next, pp.korean_char_breaks);
if g.break_after != Some(false) {
if let Some(set) = &pp.kinsoku {
if !set.allows(a, b) {

View File

@ -59,6 +59,8 @@ pub struct Glyph {
pub allow_kashidas: bool,
pub safe_tatweel_before: bool,
pub shaping_rtl: bool,
/// The character's language as a BCP 47 tag ([`designcraft_doc::language_tag`]).
pub lang: Option<&'static str>,
}
impl Glyph {
@ -752,6 +754,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
allow_kashidas: p.allow_kashidas,
safe_tatweel_before: false,
shaping_rtl: false,
lang: designcraft_doc::language_tag(&p.language),
}
}
@ -851,6 +854,7 @@ fn shape_segment(
allow_kashidas: p.allow_kashidas,
safe_tatweel_before: sg.safe_tatweel_before,
shaping_rtl: sg.rtl,
lang,
});
}
}

View File

@ -1493,3 +1493,99 @@ fn cjk_fallback_follows_the_language() {
let (primary, han) = faces_of("English: USA");
assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone()));
}
const KO_WORDS: &str = "한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다 한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다";
const KO_HANJA: &str = "大韓民國 憲法은 國民의 權利를 保障한다 大韓民國 憲法은 國民의 權利를 保障한다";
const KO_PUNCT: &str = "가나다라마。바사아자차」카타파하가、나다라마바。사아자차카」타파하가나、다라마바사。아자차카타」파하가나다、";
/// Syllables for words wider than the frame.
const KO_LONG: &str = "가나다라마바사아자차카타파하」";
/// A Korean paragraph in a narrow frame (70 pt), set in a stand-in font covering the samples.
fn korean_doc(text: &str, language: &str, para: ParaAttrs) -> (Document, StoryId) {
let chars: Vec<char> = [KO_WORDS, KO_HANJA, KO_PUNCT, KO_LONG].concat().chars().collect();
let font = designcraft_fonts::testing::font_with("DC Test Korean", &chars).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 106.0, 400.0), para);
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.font_family = Some("DC Test Korean".into());
f.over.language = Some(language.into());
});
(d, sid)
}
/// The lines (after the first) that start inside a space-separated word.
fn lines_starting_mid_word(d: &Document, sid: StoryId, text: &str) -> Vec<String> {
let cs = compose_story(d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!(lines.len() > 2, "wraps: {} lines", lines.len());
lines.iter().skip(1).filter(|l| !text[..l.range.start].ends_with(' ')).map(|l| text[l.range.clone()].to_string()).collect()
}
#[test]
fn korean_breaks_at_spaces() {
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
for align in [Align::Left, Align::LeftJustified] {
let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() };
// By script: Hangul is set word by word whatever the language.
for language in ["Korean", "English: USA"] {
let (d, sid) = korean_doc(KO_WORDS, language, para.clone());
let mid = lines_starting_mid_word(&d, sid, KO_WORDS);
assert!(mid.is_empty(), "{composer:?} {align:?} {language}: {mid:?}");
}
}
}
}
#[test]
fn korean_breaks_at_spaces_under_a_kinsoku_set() {
let set = designcraft_doc::cjk::Kinsoku::named("KoreanKinsoku");
assert!(set.is_some());
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let para = ParaAttrs { composer: Some(composer), kinsoku: Some(set.clone()), ..Default::default() };
let (d, sid) = korean_doc(KO_WORDS, "Korean", para.clone());
let mid = lines_starting_mid_word(&d, sid, KO_WORDS);
assert!(mid.is_empty(), "{composer:?}: {mid:?}");
let (d, sid) = korean_doc(KO_WORDS, "Korean", ParaAttrs { korean_char_breaks: Some(true), ..para });
assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "{composer:?}: breaks between syllables on request");
}
}
#[test]
fn korean_character_breaks_on_request() {
let para = ParaAttrs { korean_char_breaks: Some(true), composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() };
let (d, sid) = korean_doc(KO_WORDS, "Korean", para);
assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "breaks between syllables");
}
#[test]
fn hanja_in_korean_text_breaks_at_spaces() {
let para = ParaAttrs { composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() };
let (d, sid) = korean_doc(KO_HANJA, "Korean", para.clone());
let mid = lines_starting_mid_word(&d, sid, KO_HANJA);
assert!(mid.is_empty(), "{mid:?}");
// Han in Japanese text breaks between characters.
let (d, sid) = korean_doc(KO_HANJA, "Japanese", para);
assert!(!lines_starting_mid_word(&d, sid, KO_HANJA).is_empty());
}
#[test]
fn korean_lines_keep_kinsoku() {
// Words of 9 to 14 syllables and a closing bracket: in one of them the bracket is what
// overflows the frame.
let syllables: Vec<char> = KO_LONG.chars().collect();
let long = (9..=14).map(|n| format!("{}」 가나", syllables[..n].iter().collect::<String>()));
for text in std::iter::once(KO_PUNCT.to_string()).chain(long) {
let text = text.as_str();
for char_breaks in [false, true] {
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let para = ParaAttrs { korean_char_breaks: Some(char_breaks), composer: Some(composer), ..Default::default() };
let (d, sid) = korean_doc(text, "Korean", para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!(lines.len() > 1, "wraps");
let texts: Vec<&str> = lines.iter().map(|l| &text[l.range.clone()]).collect();
assert!(texts.iter().all(|t| !t.starts_with(['、', '。', '」'])), "kinsoku ({char_breaks}, {composer:?}): {texts:?}");
}
}
}
}

View File

@ -699,6 +699,9 @@ attr_set! {
/// Preserved vendor policy; non-default policies require a dedicated composer.
arabic_justification: String = String::new(),
paragraph_kashida_width: Option<f64> = None,
/// Korean text breaks between any two syllables (character-based breaking); otherwise
/// it breaks at spaces, word by word.
korean_char_breaks: bool = false,
// Keeps
keep_with_next: u32 = 0,
keep_lines_together: bool = false,

View File

@ -1377,3 +1377,22 @@ mod arabic_tests {
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight);
}
}
#[cfg(test)]
mod korean_breaks_tests {
use serde_json::json;
use crate::Session;
#[test]
fn korean_character_breaks_are_a_paragraph_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true));
s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true));
}
}

View File

@ -2088,6 +2088,15 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
d.fields.insert("p.kashidas".into(), json!(k));
}
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:"));
let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false);
if ui
.checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)")))
.changed()
{
d.fields.insert("p.koreanCharBreaks".into(), json!(kb));
}
ui.end_row();
});
}
"color" => {

View File

@ -1916,6 +1916,17 @@ const TABLE: &[(&str, [&str; 5])] = &[
"在两端对齐的阿拉伯语文本中",
],
),
("Korean Line Breaks:", ["Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "韩文换行:"]),
(
"Between syllables (not only at spaces)",
[
"Between syllables (not only at spaces)",
"Between syllables (not only at spaces)",
"Between syllables (not only at spaces)",
"Between syllables (not only at spaces)",
"在音节之间(不仅在空格处)",
],
),
("Binding:", ["Binding:", "Binding:", "Binding:", "Binding:", "装订方向:"]),
("Ruby:", ["Ruby:", "Ruby:", "Ruby:", "Ruby:", "注音:"]),
(

View File

@ -335,6 +335,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Glyph Scaling:", "تحجيم الحروف:"),
("Insert Kashidas:", "إدراج الكشيدة:"),
("In justified Arabic text", "في النص العربي المضبوط"),
("Korean Line Breaks:", "فواصل الأسطر الكورية:"),
("Between syllables (not only at spaces)", "بين المقاطع (وليس عند المسافات فقط)"),
("Minimum Space Before First Footnote:", "أقل مسافة قبل الحاشية الأولى:"),
("Space Between Footnotes:", "المسافة بين الحواشي:"),
("Offset:", "الإزاحة:"),

View File

@ -34,6 +34,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built
| Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) |
| `vert`/`vrt2` in vertical frames | done (applied to every run, Latin included); Horizontal Kana (`hkna`) becomes `vkna` |
| Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied |
| Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML |
| Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) |
| Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) |
| Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre) |
@ -46,7 +47,6 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built
Known bugs to fix first:
- Korean breaks between every syllable (it should break at spaces by default).
- Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre
(`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text.
- A justified CJK line has no stretch except Single Word Justification.