idml 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76ef12540a7d7d1805189664f88fc0c5b400b8d1240fbc495239ddb35f64822c
4
- data.tar.gz: 941893920b3e23a32c196de4286a64f10edb0e377bb716a5287ba110bc3052bd
3
+ metadata.gz: 29f97721ebdd63304b8c879eaa45764906dfa40b85363688aa9d860ccdd6f62c
4
+ data.tar.gz: 439a3e3454be081957617ce5107ae735a47d56c4dc206104d3e483c5a20db669
5
5
  SHA512:
6
- metadata.gz: 95da409c1987bac0a36ab73793c02e349d3060f313f3f4fc2670f22efffd89bd2d5f0bcfba4096dab595cf39a7843134ee0fc4f06f9e4136d477c65f44870586
7
- data.tar.gz: a74c7d9a0a1ccd22e57308890a0f6b257e9ec2d3aab7d3d8a407c23b4b52f466cd2c39f86be48d4b89e912e26fcd3a582dbdf27455d8db49a405219ede022357
6
+ metadata.gz: 0c669bcf817d1e2b8ab74c116e3a0e472b8ca5529fdfeba4f0adb85d7f1ddb78da5d422c63907cbe68f4a7c381ce5862d17b674063070bce64e9664584159b97
7
+ data.tar.gz: b427dc2a77703a71074fc541eb8eea36485dc78607dea9faa8f13d3ca69c70e013a749374ef8abca81057bdf5e7563f6717bea30e45ad967a5836ead3e325d6a
@@ -0,0 +1,24 @@
1
+ # TODO PDF 123: Keep options (widow/orphan control)
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - `StyleResolver::Paragraph` carries `keep_all_lines_together`
8
+ from the PSR.
9
+ - `TextFrameRenderer.consume_paragraphs` pre-measures the paragraph
10
+ (TextEngine::Measurement) and, when KeepAllLinesTogether is set,
11
+ the paragraph cannot fully fit the remaining frame space, and at
12
+ least one paragraph was already placed, pushes the whole
13
+ paragraph to the next frame via the chain state — no widowed
14
+ first lines. The first paragraph in a frame never pushes, so
15
+ progress is always made.
16
+
17
+ ## Known limitations
18
+
19
+ - KeepLinesTogether with per-side KeepFirstLines / KeepLastLines
20
+ counts (partial keep windows) is not modeled — only the
21
+ whole-paragraph KeepAllLinesTogether form.
22
+ - KeepWithNext (binding a paragraph to the next) is not modeled.
23
+ - Vertical writing mode does not apply keep options (column model
24
+ differs).
@@ -0,0 +1,23 @@
1
+ # TODO PDF 124: Tate-chu-yoko and vertical multi-run columns
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - Multi-run column continuity (bug fix): every run in a vertical
8
+ frame used to restart at column 0, overlapping earlier runs.
9
+ `VerticalTextLayout.layout` takes `start_column`; the frame
10
+ renderer threads the next-unused column across runs and
11
+ paragraphs, and persists it in the chain state (`column_offset`)
12
+ so vertical text continues correctly into the next frame of a
13
+ chain.
14
+ - Tate-chu-yoko (`Tatechuyoko="true"` on the CSR — note the
15
+ schema's lowercase c): the run's glyphs render HORIZONTALLY,
16
+ sharing one baseline at the top of their column slot and centered
17
+ in it, consuming one column. Groups wider than the column height
18
+ fall back to stacked layout. StyledRun carries `tatechuyoko`.
19
+
20
+ ## Remaining from TODO 13
21
+
22
+ Mojikumi (punctuation spacing classes) is the last documented
23
+ stretch goal.
@@ -0,0 +1,22 @@
1
+ # TODO PDF 125: CJK/Latin automatic script spacing (mojikumi subset)
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - `TextEngine::CjkLayout.apply_script_spacing` widens the leading
8
+ glyph by an eighth em at every CJK ↔ ASCII alphanumeric boundary
9
+ — InDesign's default inter-script auto spacing (a mojikumi
10
+ behavior).
11
+ - Wired inside `TextEngine::Shaper#shape`, so every shaping path
12
+ (horizontal frame text, footnotes, ruby, vertical columns)
13
+ applies it uniformly — one integration point, no per-caller
14
+ changes.
15
+ - Punctuation neighbors are ignored (only letters/digits form
16
+ boundaries).
17
+
18
+ ## Remaining from TODO 13
19
+
20
+ Class-based mojikumi (per-pair punctuation compression tables,
21
+ line-end punctuation hanging beyond half-em) remains a documented
22
+ stretch goal.
@@ -0,0 +1,22 @@
1
+ # TODO PDF 126: Package#part memoization
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## Problem
6
+
7
+ `Package#part(name)` re-read and re-parsed the zip entry on every
8
+ call. Rendering resolves `story_by_id` per frame plus preferences
9
+ (Footnote.option), styles, and graphics repeatedly — each lookup
10
+ decompressed and re-parsed the same XML.
11
+
12
+ ## What was done
13
+
14
+ Parsed parts are memoized in a per-`Package` hash
15
+ (`parse_part` remains the single read+parse path; `each_part` /
16
+ `read_part` stay uncached raw reads for round-trip byte fidelity).
17
+
18
+ ## Acceptance criteria
19
+
20
+ - [x] Repeated `part(name)` calls return the same object.
21
+ - [x] Different parts do not share objects.
22
+ - [x] Whole-package round-trip specs unchanged (raw reads).
@@ -20,9 +20,11 @@ kinsoku shori, ruby annotations).
20
20
  frame width (previously they overflowed as one long line) — this
21
21
  also fixes horizontal CJK wrapping.
22
22
 
23
- Remaining stretch goals: tate-chu-yoko, mojikumi, Latin rotation in
24
- vertical mode, vertical ruby placement. See
25
- `lib/idml/text_engine/cjk_layout.rb` and
23
+ 2026-08-20 completions: tate-chu-yoko (TODO 124), Latin rotation
24
+ and vertical ruby (TODO 122), CJK/Latin automatic script spacing
25
+ (TODO 125, a mojikumi subset wired into Shaper). The remaining
26
+ stretch goal is full class-based mojikumi (per-pair punctuation
27
+ compression tables). See `lib/idml/text_engine/cjk_layout.rb` and
26
28
  `lib/idml/text_engine/vertical_text_layout.rb`.
27
29
 
28
30
  ## Progress 2026-08-17
data/lib/idml/package.rb CHANGED
@@ -134,13 +134,23 @@ module Idml
134
134
  # Returns a typed part instance for `name` when a class is registered
135
135
  # for that file pattern (e.g. Designmap for designmap.xml). Falls
136
136
  # back to Parts::Raw — a lossless XML wrapper — for unmodeled parts.
137
+ # Parsed parts are memoized: rendering resolves stories,
138
+ # preferences, and styles repeatedly (per frame), and each
139
+ # un-memoized read would re-decompress and re-parse the zip
140
+ # entry.
137
141
  def part(name)
142
+ @part_cache ||= {}
143
+ @part_cache[name] ||= parse_part(name)
144
+ end
145
+
146
+ def parse_part(name)
138
147
  xml = read_part(name)
139
148
  klass = Idml::Parts.class_for(name)
140
149
  return Idml::Parts::Raw.from_xml(xml) unless klass
141
150
 
142
151
  klass.from_xml(xml)
143
152
  end
153
+ private :parse_part
144
154
 
145
155
  def each_part
146
156
  return enum_for(:each_part) unless block_given?
@@ -177,17 +177,20 @@ module Idml
177
177
  layout_frame = layout_frame(context.item, box)
178
178
  left_limit = layout_frame.x + (layout_frame.inset_left || 0)
179
179
  resume_vertical_paragraph(state)
180
+ next_column = state.column_offset || 0
180
181
  pending = 0
181
182
 
182
183
  state.paragraphs.each do |paragraph|
183
- remaining_runs = vertical_paragraph(
184
- canvas, paragraph, context, layout_frame, font, left_limit
184
+ remaining_runs, next_column = vertical_paragraph(
185
+ canvas, paragraph, context, layout_frame, font,
186
+ left_limit, next_column
185
187
  )
186
188
  if remaining_runs.empty?
187
189
  pending += 1
188
190
  else
189
191
  state.current_paragraph = paragraph
190
192
  state.runs_remaining = remaining_runs
193
+ state.column_offset = next_column
191
194
  break
192
195
  end
193
196
  end
@@ -198,7 +201,9 @@ module Idml
198
201
 
199
202
  # Folds a chain-resumed paragraph (runs_remaining) back to
200
203
  # the head of the paragraph list so the vertical path sees a
201
- # uniform sequence.
204
+ # uniform sequence. The consumed column offset persists in
205
+ # the state so the next frame continues right where this
206
+ # frame's columns ended.
202
207
  def self.resume_vertical_paragraph(state)
203
208
  return unless state.current_paragraph
204
209
 
@@ -209,10 +214,13 @@ module Idml
209
214
  end
210
215
  private_class_method :resume_vertical_paragraph
211
216
 
212
- # Renders one paragraph's runs as vertical columns. Returns
213
- # the runs that did not fit (empty when all placed).
217
+ # Renders one paragraph's runs as vertical columns starting
218
+ # at `start_column`. Returns [runs that did not fit (empty
219
+ # when all placed), the next unused column index].
214
220
  def self.vertical_paragraph(canvas, paragraph, context,
215
- layout_frame, font, left_limit)
221
+ layout_frame, font, left_limit,
222
+ start_column)
223
+ next_column = start_column
216
224
  paragraph.runs.each_with_index do |run, index|
217
225
  size = run.point_size || DEFAULT_SIZE
218
226
  leading = TextEngine::VerticalLayout.leading_for(
@@ -220,25 +228,72 @@ module Idml
220
228
  )
221
229
  glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
222
230
  size: size)
223
- positioned, columns = TextEngine::VerticalTextLayout.layout(
224
- glyphs: glyphs, frame: layout_frame, leading: leading,
225
- size: size
226
- )
227
- unless vertical_fits?(layout_frame, leading, columns,
231
+ grouped = tatechuyoko_group(run, glyphs, layout_frame,
232
+ leading, size, next_column)
233
+ positioned, next_column = grouped ||
234
+ TextEngine::VerticalTextLayout.layout(
235
+ glyphs: glyphs, frame: layout_frame, leading: leading,
236
+ size: size, start_column: next_column
237
+ )
238
+ unless vertical_fits?(layout_frame, leading, next_column,
228
239
  left_limit)
229
- return paragraph.runs[index..]
240
+ return [paragraph.runs[index..], start_column]
230
241
  end
231
242
 
232
- positioned.each do |glyph|
233
- emit_vertical_glyph(canvas, glyph, run, context, size)
234
- end
243
+ emit_vertical_run(canvas, positioned, grouped, run, context,
244
+ size)
235
245
  emit_vertical_ruby(canvas, run, positioned, layout_frame,
236
246
  size, context)
237
247
  end
238
- []
248
+ [[], next_column]
239
249
  end
240
250
  private_class_method :vertical_paragraph
241
251
 
252
+ # A tate-chu-yoko group for runs that declare it (and whose
253
+ # glyphs fit horizontally in a column slot); nil otherwise.
254
+ def self.tatechuyoko_group(run, glyphs, layout_frame, leading,
255
+ size, start_column)
256
+ return nil unless run.tatechuyoko
257
+
258
+ TextEngine::VerticalTextLayout.tatechuyoko_group(
259
+ glyphs: glyphs, frame: layout_frame, leading: leading,
260
+ size: size, start_column: start_column
261
+ )
262
+ end
263
+ private_class_method :tatechuyoko_group
264
+
265
+ # Emits a vertical run's glyphs: rotated/normal vertical
266
+ # handling for stacked columns, plain upright emission for
267
+ # tate-chu-yoko groups.
268
+ def self.emit_vertical_run(canvas, positioned, grouped, run,
269
+ context, size)
270
+ if grouped
271
+ positioned.each do |glyph|
272
+ emit_upright_glyph(canvas, glyph, run, context, size)
273
+ end
274
+ else
275
+ positioned.each do |glyph|
276
+ emit_vertical_glyph(canvas, glyph, run, context, size)
277
+ end
278
+ end
279
+ end
280
+ private_class_method :emit_vertical_run
281
+
282
+ def self.emit_upright_glyph(canvas, positioned, run, context,
283
+ size)
284
+ text = [positioned.codepoint].pack("U")
285
+ text_kwargs = CharacterStyle.text_kwargs(
286
+ run,
287
+ {
288
+ at: [positioned.x, positioned.y],
289
+ font: font_for_run(run, context),
290
+ size: size,
291
+ },
292
+ )
293
+ canvas.text(text, **text_kwargs)
294
+ end
295
+ private_class_method :emit_upright_glyph
296
+
242
297
  def self.vertical_fits?(layout_frame, leading, columns, left_limit)
243
298
  right = layout_frame.x + layout_frame.width -
244
299
  (layout_frame.inset_right || 0)
@@ -576,7 +631,9 @@ module Idml
576
631
  pending = 0
577
632
  state.paragraphs.each do |paragraph|
578
633
  break if cursor_y < bottom_limit
579
- break if paragraph_break?(paragraph) && pending.positive?
634
+ break if paragraph_deferred?(paragraph, layout_frame, font,
635
+ cursor_y, bottom_limit,
636
+ pending.positive?)
580
637
 
581
638
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
582
639
  canvas, paragraph, paragraph.runs, context,
@@ -908,6 +965,26 @@ module Idml
908
965
  end
909
966
  private_class_method :layout_run
910
967
 
968
+ # True when the paragraph should move wholly to the next
969
+ # frame: a StartParagraph forced break, or KeepAllLines-
970
+ # Together with insufficient remaining space (never for the
971
+ # frame's first paragraph, so progress is always made).
972
+ def self.paragraph_deferred?(paragraph, layout_frame, font,
973
+ cursor_y, bottom_limit, placed_any)
974
+ return true if paragraph_break?(paragraph) && placed_any
975
+ return false unless paragraph.keep_all_lines_together
976
+ return false unless placed_any
977
+
978
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
979
+ layout_frame, paragraph.right_indent || 0
980
+ )
981
+ height = TextEngine::Measurement.paragraph_height(
982
+ paragraph, font, wrap_width
983
+ )
984
+ (cursor_y - height) < bottom_limit
985
+ end
986
+ private_class_method :paragraph_deferred?
987
+
911
988
  # True when the paragraph requests a forced break to the
912
989
  # next frame/column (StartParagraph). All break flavors act
913
990
  # at frame/column granularity.
@@ -25,6 +25,9 @@ module Idml
25
25
  :current_paragraph,
26
26
  :runs_remaining,
27
27
  :char_cursor,
28
+ # Vertical-writing path: columns already consumed when the
29
+ # paragraph chain continues into the next frame.
30
+ :column_offset,
28
31
  keyword_init: true,
29
32
  )
30
33
 
@@ -48,6 +48,7 @@ module Idml
48
48
  :ruby_string,
49
49
  :ruby_font_size,
50
50
  :ruby_position,
51
+ :tatechuyoko,
51
52
  keyword_init: true,
52
53
  )
53
54
 
@@ -70,6 +71,9 @@ module Idml
70
71
  # Forced paragraph break (StartParagraph: NextPage /
71
72
  # NextColumn / NextFrame / NextOddPage / NextEvenPage).
72
73
  :start_paragraph,
74
+ # Widow/orphan control: KeepAllLinesTogether pushes the whole
75
+ # paragraph to the next frame when it cannot fully fit.
76
+ :keep_all_lines_together,
73
77
  :bullets_and_numbering_list_type,
74
78
  :bullet_character_value,
75
79
  :bullets_text_after,
@@ -200,6 +204,9 @@ module Idml
200
204
  ),
201
205
  start_paragraph: resolve_attr(style_lookup, psr,
202
206
  :start_paragraph),
207
+ keep_all_lines_together: resolve_attr(
208
+ style_lookup, psr, :keep_all_lines_together
209
+ ),
203
210
  drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
204
211
  drop_cap_characters: resolve_attr(style_lookup, psr,
205
212
  :drop_cap_characters),
@@ -390,6 +397,7 @@ module Idml
390
397
  ruby_string: resolve_csr(style_lookup, csr, :ruby_string),
391
398
  ruby_font_size: resolve_csr(style_lookup, csr, :ruby_font_size),
392
399
  ruby_position: resolve_csr(style_lookup, csr, :ruby_position),
400
+ tatechuyoko: resolve_csr(style_lookup, csr, :tatechuyoko),
393
401
  )
394
402
  end
395
403
  private_class_method :text_run
@@ -7,6 +7,38 @@ module Idml
7
7
  module CjkLayout
8
8
  module_function
9
9
 
10
+ # Mojikumi subset: automatic inter-script spacing — an eighth
11
+ # em is inserted between adjacent CJK and ASCII alphanumeric
12
+ # glyphs (widenning the leading glyph of each pair), matching
13
+ # InDesign's default CJK/Latin auto spacing.
14
+ SCRIPT_SPACING_EM = 0.125
15
+
16
+ def apply_script_spacing(glyphs, size)
17
+ spacing = size * SCRIPT_SPACING_EM
18
+ (0...(glyphs.length - 1)).each do |index|
19
+ leading = glyphs[index]
20
+ following = glyphs[index + 1]
21
+ next unless script_boundary?(leading.codepoint,
22
+ following.codepoint)
23
+
24
+ leading.width += spacing
25
+ end
26
+ glyphs
27
+ end
28
+
29
+ # True when exactly one of the pair is CJK and the other is
30
+ # an ASCII letter or digit.
31
+ def script_boundary?(left, right)
32
+ (cjk?(left) && ascii_alnum?(right)) ||
33
+ (ascii_alnum?(left) && cjk?(right))
34
+ end
35
+
36
+ def ascii_alnum?(codepoint)
37
+ (0x30..0x39).cover?(codepoint) ||
38
+ (0x41..0x5A).cover?(codepoint) ||
39
+ (0x61..0x7A).cover?(codepoint)
40
+ end
41
+
10
42
  CJK_RANGES = [
11
43
  0x3000..0x303F, # CJK Symbols and Punctuation
12
44
  0x3040..0x309F, # Hiragana
@@ -13,18 +13,19 @@ module Idml
13
13
  PositionedGlyph = Struct.new(:codepoint, :x, :y, keyword_init: true)
14
14
 
15
15
  # Stacks `glyphs` into columns of at most the frame's text
16
- # height; column 0 is the rightmost. Returns
17
- # [positioned_glyphs, column_count].
18
- def self.layout(glyphs:, frame:, leading:, size:)
16
+ # height; column 0 is the rightmost. `start_column` places
17
+ # the first column after already-used columns (multi-run
18
+ # frames). Returns [positioned_glyphs, next_column_index].
19
+ def self.layout(glyphs:, frame:, leading:, size:, start_column: 0)
19
20
  lines = TextEngine::LineBreaker.break(
20
21
  glyphs: glyphs, frame_width: column_height(frame),
21
22
  )
22
23
  positioned = []
23
- lines.each_with_index do |line, column_index|
24
+ lines.each_with_index do |line, index|
24
25
  stack_column(positioned, line.glyphs, frame, leading, size,
25
- column_index)
26
+ index + start_column)
26
27
  end
27
- [positioned, lines.length]
28
+ [positioned, start_column + lines.length]
28
29
  end
29
30
 
30
31
  # The frame's usable vertical extent for one column of text.
@@ -47,6 +48,32 @@ module Idml
47
48
  end
48
49
  private_class_method :stack_column
49
50
 
51
+ # Positions a tate-chu-yoko group (e.g. the digits "12" kept
52
+ # horizontal inside vertical text): glyphs share one baseline
53
+ # at the top of the given column slot and advance left-to-
54
+ # right, centered in the slot. Returns
55
+ # [positioned_glyphs, next_column_index]; nil when the group
56
+ # is wider than the column height (caller falls back to
57
+ # stacked layout).
58
+ def self.tatechuyoko_group(glyphs:, frame:, leading:, size:,
59
+ start_column:)
60
+ total = glyphs.sum(&:width)
61
+ return nil if total > column_height(frame)
62
+
63
+ right = frame.x + frame.width - (frame.inset_right || 0)
64
+ slot_center = right - ((start_column + 0.5) * leading)
65
+ x = slot_center - (total / 2.0)
66
+ baseline_y = frame.y + frame.height - (frame.inset_top || 0) - size
67
+ positioned = glyphs.map do |glyph|
68
+ glyph_position = PositionedGlyph.new(
69
+ codepoint: glyph.codepoint, x: x, y: baseline_y,
70
+ )
71
+ x += glyph.width
72
+ glyph_position
73
+ end
74
+ [positioned, start_column + 1]
75
+ end
76
+
50
77
  # Vertical advance of one glyph — its measured width (the em
51
78
  # for the common square-CJK case).
52
79
  def self.advance(glyph)
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.9.6"
4
+ VERSION = "0.9.8"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -181,6 +181,10 @@ files:
181
181
  - TODO.pdf/120-start-paragraph-breaks.md
182
182
  - TODO.pdf/121-cell-edge-strokes.md
183
183
  - TODO.pdf/122-vertical-latin-ruby.md
184
+ - TODO.pdf/123-keep-options.md
185
+ - TODO.pdf/124-tatechuyoko-vertical.md
186
+ - TODO.pdf/125-script-spacing.md
187
+ - TODO.pdf/126-part-cache.md
184
188
  - TODO.pdf/13-cjk-text-layout.md
185
189
  - TODO.pdf/14-page-item-element-models.md
186
190
  - TODO.pdf/15-wire-spread-children.md