Module: Idml::TextEngine::CjkLayout

Defined in:
lib/idml/text_engine/cjk_layout.rb

Overview

CJK text layout utilities: character classification, kinsoku shori (line-breaking rules), and vertical writing-mode helpers.

Constant Summary collapse

SCRIPT_SPACING_EM =

Mojikumi subset: automatic inter-script spacing — an eighth em is inserted between adjacent CJK and ASCII alphanumeric glyphs (widenning the leading glyph of each pair), matching InDesign's default CJK/Latin auto spacing. A named mojikumi set's OverrideMojikumiAki entries take precedence when given: the Desired value (em units) applies to each matching class pair (TODO 145).

0.125
CLASS_IDEOGRAPH =

Mojikumi character classes (InDesign mojikumi table rows).

1
CLASS_OPENING =
2
CLASS_CLOSING =
3
CLASS_COMMA_PERIOD =
4
CLASS_MIDDLE_DOT =
5
CLASS_DIGIT =
6
CLASS_LATIN =
7
COMMA_PERIOD_CODEPOINTS =
[
  0x3001, # 、 ideographic comma
  0x3002, # 。 ideographic full stop
].freeze
CLOSING_CODEPOINTS =
[
  0x3009, # 〉
  0x300B, # 》
  0x300D, # 」
  0x300F, # 』
  0x3011, # 】
  0x3015, # 〕
  0xFF09, # )fullwidth right paren
  0xFF3D, # ]
  0xFF5D, # }
].freeze
TRAILING_COMPRESSION_CODEPOINTS =

Mojikumi subset 2: line-end punctuation compression — full-width closing punctuation occupying a line's final position renders at half advance (行末約物半角詰め), tightening the line as InDesign's default mojikumi does.

[
  0x3001, # 、 ideographic comma
  0x3002, # 。 ideographic full stop
  0x3009, # 〉
  0x300B, # 》
  0x300D, # 」
  0x300F, # 』
  0x3011, # 】
  0x3015, # 〕
  0xFF09, # )fullwidth right paren
  0xFF3D, # ]
  0xFF5D, # }
  0x30FC, # ー prolonged sound mark
].freeze
OPENING_CODEPOINTS =

Class-based mojikumi pair compression: a full-width closing/middle glyph followed by another full-width punctuation glyph compresses to half advance within the line (。」、ー) … pairs), matching InDesign's default mojikumi behavior for consecutive 約物.

[
  0x300C, # 「
  0x300E, # 『
  0x3008, # 〈
  0x300A, # 《
  0x3010, # 【
  0x3014, # 〔
  0xFF08, # (fullwidth left paren
  0xFF3B, # [
  0xFF5B, # {
].freeze
MIDDLE_CODEPOINTS =
[
  0x30FB, # ・ fullwidth middle dot
  0x30FC, # ー prolonged sound mark
  0x2025, # ‥ two-dot leader
  0x2026, # … ellipsis
].freeze
CJK_RANGES =
[
  0x3000..0x303F, # CJK Symbols and Punctuation
  0x3040..0x309F, # Hiragana
  0x30A0..0x30FF, # Katakana
  0x3400..0x4DBF, # CJK Extension A
  0x4E00..0x9FFF, # CJK Unified Ideographs
  0xAC00..0xD7AF, # Hangul Syllables
  0xF900..0xFAFF, # CJK Compatibility Ideographs
  0xFF00..0xFFEF, # Halfwidth and Fullwidth Forms
].freeze
FORBIDDEN_LINE_START =

Characters forbidden at line start (kinsoku shori).

%w[
  、 。, . 。 : ; ? ソ ゙ ゚ ! ! ” 〉 》 」 』 】
  〕 } ) 」 ゛ ゝ ゞ 〃 々 〆 〇 ー 〜
  ぁ ぃ ぅ ぇ ぉ っ ゃ ゅ ょ ゎ
  ァ ィ ゥ ェ ォ ッ ャ ュ ョ ヮ
  ぁ ぃ ぅ ぇ ぉ
].map(&:codepoints).flatten.freeze
FORBIDDEN_LINE_END =

Characters forbidden at line end (kinsoku shori).

%w[
  “ 〈 《 「 『 【 〔 { ( 「
].map(&:codepoints).flatten.freeze

Class Method Summary collapse

Class Method Details

.adjust_boundary(prev_line, current_line) ⇒ Object

Adjust the boundary between two lines according to kinsoku rules.



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/idml/text_engine/cjk_layout.rb', line 277

def adjust_boundary(prev_line, current_line)
  prev = dup_line(prev_line)
  curr = dup_line(current_line)

  # Move forbidden-start chars from current line start to prev line end.
  until curr.glyphs.empty? || !forbidden_start?(curr.glyphs.first.codepoint)
    moved = curr.glyphs.shift
    prev.glyphs << moved
    prev.width += moved.width
    curr.width -= moved.width
  end

  # Move forbidden-end chars from prev line end to current line start.
  until prev.glyphs.empty? || !forbidden_end?(prev.glyphs.last.codepoint)
    moved = prev.glyphs.pop
    prev.width -= moved.width
    curr.glyphs.unshift(moved)
    curr.width += moved.width
  end

  [prev, curr]
end

.aki_em(left, right, aki_overrides) ⇒ Object

Spacing (em units) between an adjacent pair: the named set's Desired aki for a matching class-pair override, else the default eighth-em at CJK/Latin script boundaries, else none.



62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/idml/text_engine/cjk_layout.rb', line 62

def aki_em(left, right, aki_overrides)
  override = aki_overrides.find do |entry|
    entry.side_is_after_target &&
      entry.target_mojikumi_class == mojikumi_class(left) &&
      entry.side_mojikumi_class == mojikumi_class(right)
  end
  return override.desired if override&.desired

  return SCRIPT_SPACING_EM if script_boundary?(left, right)

  nil
end

.apply_kinsoku(lines) ⇒ Object

Apply kinsoku shori to an array of Line objects. Moves forbidden-start chars from the beginning of a line to the end of the previous line, and forbidden-end chars from the end of a line to the beginning of the next line.



258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/idml/text_engine/cjk_layout.rb', line 258

def apply_kinsoku(lines)
  return lines if lines.length < 2

  result = [dup_line(lines.first)]
  lines[1..].each do |line|
    prev = result.last
    curr = dup_line(line)
    adjusted_prev, adjusted_current = adjust_boundary(prev, curr)
    result[-1] = adjusted_prev
    result << adjusted_current
  end
  result
end

.apply_line_end_compression(lines) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
# File 'lib/idml/text_engine/cjk_layout.rb', line 134

def apply_line_end_compression(lines)
  lines.each do |line|
    last = line.glyphs.last
    next unless last
    next unless compressible?(last.codepoint)

    last.width /= 2.0
    line.width = line.glyphs.sum(&:width)
  end
  lines
end

.apply_pair_compression(lines) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/idml/text_engine/cjk_layout.rb', line 174

def apply_pair_compression(lines)
  lines.each do |line|
    glyphs = line.glyphs
    (0...(glyphs.length - 1)).each do |index|
      leading = glyphs[index]
      following = glyphs[index + 1]
      next unless pair_compressible?(leading, following)

      leading.width /= 2.0
    end
    line.width = glyphs.sum(&:width)
  end
  lines
end

.apply_script_spacing(glyphs, size, aki_overrides = []) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/idml/text_engine/cjk_layout.rb', line 45

def apply_script_spacing(glyphs, size, aki_overrides = [])
  (0...(glyphs.length - 1)).each do |index|
    leading = glyphs[index]
    following = glyphs[index + 1]
    em = aki_em(leading.codepoint, following.codepoint,
                aki_overrides)
    next unless em&.positive?

    leading.width += em * size
  end
  glyphs
end

.ascii_alnum?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


109
110
111
112
113
# File 'lib/idml/text_engine/cjk_layout.rb', line 109

def ascii_alnum?(codepoint)
  (0x30..0x39).cover?(codepoint) ||
    (0x41..0x5A).cover?(codepoint) ||
    (0x61..0x7A).cover?(codepoint)
end

.cjk?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


237
238
239
# File 'lib/idml/text_engine/cjk_layout.rb', line 237

def cjk?(codepoint)
  CJK_RANGES.any? { |range| range.include?(codepoint) }
end

.closing?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


199
200
201
# File 'lib/idml/text_engine/cjk_layout.rb', line 199

def closing?(codepoint)
  TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
end

.compressible?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/idml/text_engine/cjk_layout.rb', line 146

def compressible?(codepoint)
  TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
end

.contains_cjk?(text) ⇒ Boolean

Check if text contains any CJK characters.

Returns:

  • (Boolean)


250
251
252
# File 'lib/idml/text_engine/cjk_layout.rb', line 250

def contains_cjk?(text)
  text.each_codepoint.any? { |cp| cjk?(cp) }
end

.digit_class?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


90
91
92
93
# File 'lib/idml/text_engine/cjk_layout.rb', line 90

def digit_class?(codepoint)
  (0x30..0x39).cover?(codepoint) ||
    (0xFF10..0xFF19).cover?(codepoint)
end

.dup_line(line) ⇒ Object



272
273
274
# File 'lib/idml/text_engine/cjk_layout.rb', line 272

def dup_line(line)
  Line.new(line.glyphs.dup, line.width, line.x_offset)
end

.forbidden_end?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


245
246
247
# File 'lib/idml/text_engine/cjk_layout.rb', line 245

def forbidden_end?(codepoint)
  FORBIDDEN_LINE_END.include?(codepoint)
end

.forbidden_start?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


241
242
243
# File 'lib/idml/text_engine/cjk_layout.rb', line 241

def forbidden_start?(codepoint)
  FORBIDDEN_LINE_START.include?(codepoint)
end

.fullwidth_digit?(codepoint) ⇒ Boolean

Detect if a codepoint is a CJK full-width digit (0-9).

Returns:

  • (Boolean)


301
302
303
# File 'lib/idml/text_engine/cjk_layout.rb', line 301

def fullwidth_digit?(codepoint)
  codepoint.between?(0xFF10, 0xFF19)
end

.fullwidth_punct?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


207
208
209
210
# File 'lib/idml/text_engine/cjk_layout.rb', line 207

def fullwidth_punct?(codepoint)
  closing?(codepoint) || middle?(codepoint) ||
    OPENING_CODEPOINTS.include?(codepoint)
end

.latin_class?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
98
99
100
# File 'lib/idml/text_engine/cjk_layout.rb', line 95

def latin_class?(codepoint)
  (0x41..0x5A).cover?(codepoint) ||
    (0x61..0x7A).cover?(codepoint) ||
    (0xFF21..0xFF3A).cover?(codepoint) ||
    (0xFF41..0xFF5A).cover?(codepoint)
end

.middle?(codepoint) ⇒ Boolean

Returns:

  • (Boolean)


203
204
205
# File 'lib/idml/text_engine/cjk_layout.rb', line 203

def middle?(codepoint)
  MIDDLE_CODEPOINTS.include?(codepoint)
end

.mojikumi_class(codepoint) ⇒ Object

The mojikumi class of a codepoint, or nil outside the CJK punctuation / script classes. Ideographs (the default CJK class) win only when no special class matches.



78
79
80
81
82
83
84
85
86
87
88
# File 'lib/idml/text_engine/cjk_layout.rb', line 78

def mojikumi_class(codepoint)
  return CLASS_OPENING if OPENING_CODEPOINTS.include?(codepoint)
  return CLASS_COMMA_PERIOD if COMMA_PERIOD_CODEPOINTS.include?(codepoint)
  return CLASS_CLOSING if CLOSING_CODEPOINTS.include?(codepoint)
  return CLASS_MIDDLE_DOT if MIDDLE_CODEPOINTS.include?(codepoint)
  return CLASS_DIGIT if digit_class?(codepoint)
  return CLASS_LATIN if latin_class?(codepoint)
  return CLASS_IDEOGRAPH if cjk?(codepoint)

  nil
end

.pair_compressible?(leading, following) ⇒ Boolean

Closing/middle glyph followed by full-width punctuation compresses the leading glyph to half advance.

Returns:

  • (Boolean)


191
192
193
194
195
196
197
# File 'lib/idml/text_engine/cjk_layout.rb', line 191

def pair_compressible?(leading, following)
  return false unless fullwidth_punct?(following.codepoint)

  closing?(leading.codepoint) ||
    (middle?(leading.codepoint) &&
     !OPENING_CODEPOINTS.include?(following.codepoint))
end

.script_boundary?(left, right) ⇒ Boolean

True when exactly one of the pair is CJK and the other is an ASCII letter or digit.

Returns:

  • (Boolean)


104
105
106
107
# File 'lib/idml/text_engine/cjk_layout.rb', line 104

def script_boundary?(left, right)
  (cjk?(left) && ascii_alnum?(right)) ||
    (ascii_alnum?(left) && cjk?(right))
end

.tate_chu_yoko?(codepoint) ⇒ Boolean

Tate-Chu-Yoko candidate: 1-2 digit horizontal runs in vertical text.

Returns:

  • (Boolean)


306
307
308
# File 'lib/idml/text_engine/cjk_layout.rb', line 306

def tate_chu_yoko?(codepoint)
  codepoint.between?(0x30, 0x39) || fullwidth_digit?(codepoint)
end

.vertical_mode?(orientation) ⇒ Boolean

Check if a StoryOrientation value indicates vertical writing.

Returns:

  • (Boolean)


311
312
313
# File 'lib/idml/text_engine/cjk_layout.rb', line 311

def vertical_mode?(orientation)
  ["TopToBottom", "RightToLeftTopToBottom"].include?(orientation)
end