arrolio 0.1.0 → 0.1.2

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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Rakefile +15 -0
  4. data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
  5. data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
  6. data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
  7. data/TODO.complete/71-cover-layout-parity.md +45 -0
  8. data/TODO.complete/72-table-layout-parity.md +49 -0
  9. data/TODO.complete/73-toc-leaders-parity.md +55 -0
  10. data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
  11. data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
  12. data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
  13. data/TODO.complete/77-text-formatting-parity.md +55 -0
  14. data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
  15. data/TODO.complete/79-line-breaking-parity.md +80 -0
  16. data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
  17. data/TODO.complete/81-conditional-style-refinements.md +70 -0
  18. data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
  19. data/TODO.complete/83-nested-list-support.md +74 -0
  20. data/TODO.complete/84-figure-svg-rendering.md +70 -0
  21. data/TODO.complete/85-locality-reference-formatting.md +48 -0
  22. data/TODO.complete/86-list-item-text-overflow.md +80 -0
  23. data/TODO.complete/87-odd-even-page-templates.md +55 -0
  24. data/TODO.complete/88-hyphenation-support.md +52 -0
  25. data/TODO.complete/89-table-rowspan.md +49 -0
  26. data/TODO.complete/90-mathml-stacked-layout.md +65 -0
  27. data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
  28. data/TODO.complete/92-table-cell-height-parity.md +66 -0
  29. data/TODO.complete/93-figure-text-searchability.md +49 -0
  30. data/TODO.complete/94-fo-space-resolution.md +63 -0
  31. data/TODO.complete/95-title-header-placement.md +55 -0
  32. data/TODO.complete/96-pagination-drift-parity.md +517 -0
  33. data/TODO.complete/README.md +58 -38
  34. data/lib/arrolio/config_driven_pipeline.rb +45 -1
  35. data/lib/arrolio/content/document.rb +4 -3
  36. data/lib/arrolio/content/section.rb +3 -0
  37. data/lib/arrolio/content/table.rb +25 -14
  38. data/lib/arrolio/embedded_block_extractor.rb +54 -0
  39. data/lib/arrolio/engine/paged.rb +109 -28
  40. data/lib/arrolio/flavor/manifest.rb +0 -1
  41. data/lib/arrolio/flavor/registry.rb +1 -1
  42. data/lib/arrolio/flowable.rb +21 -0
  43. data/lib/arrolio/flowables/figure_flowable.rb +17 -0
  44. data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
  45. data/lib/arrolio/flowables/group_flowable.rb +57 -0
  46. data/lib/arrolio/flowables/list_flowable.rb +46 -18
  47. data/lib/arrolio/flowables/note_flowable.rb +84 -9
  48. data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
  49. data/lib/arrolio/flowables/positioned_block.rb +55 -0
  50. data/lib/arrolio/flowables/rotated_text.rb +51 -0
  51. data/lib/arrolio/flowables/table_flowable.rb +303 -71
  52. data/lib/arrolio/flowables/text_flowable.rb +67 -11
  53. data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
  54. data/lib/arrolio/flowables.rb +4 -0
  55. data/lib/arrolio/font/embedder.rb +22 -3
  56. data/lib/arrolio/font_scanner.rb +133 -0
  57. data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
  58. data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
  59. data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
  60. data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
  61. data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
  62. data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
  63. data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
  64. data/lib/arrolio/generic_adapter.rb +263 -317
  65. data/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
  66. data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
  67. data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
  68. data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
  69. data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
  70. data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
  71. data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
  72. data/lib/arrolio/generic_flow_builder.rb +92 -187
  73. data/lib/arrolio/glyph_measurer.rb +16 -5
  74. data/lib/arrolio/inline_run.rb +2 -1
  75. data/lib/arrolio/layout_spec/page_template.rb +14 -8
  76. data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
  77. data/lib/arrolio/math_ml.rb +7 -0
  78. data/lib/arrolio/output/page.rb +5 -4
  79. data/lib/arrolio/output/placed_box.rb +5 -2
  80. data/lib/arrolio/renderer/pdf/assets.rb +94 -0
  81. data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
  82. data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
  83. data/lib/arrolio/renderer/pdf.rb +140 -215
  84. data/lib/arrolio/style/definition.rb +7 -3
  85. data/lib/arrolio/style/registry.rb +6 -1
  86. data/lib/arrolio/table/auto_layout.rb +19 -10
  87. data/lib/arrolio/table/grid.rb +105 -0
  88. data/lib/arrolio/table.rb +1 -0
  89. data/lib/arrolio/text_layout/greedy.rb +15 -1
  90. data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -92
  91. data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
  92. data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
  93. data/lib/arrolio/text_layout/line.rb +10 -2
  94. data/lib/arrolio/version.rb +1 -1
  95. data/lib/arrolio.rb +8 -4
  96. data/scripts/parity_check.rb +52 -0
  97. data/scripts/parity_diff.rb +89 -0
  98. data/scripts/xsl_profiles/standoc.yml +8 -2
  99. data/scripts/xsl_to_config.rb +28 -1
  100. metadata +91 -5
@@ -5,57 +5,77 @@ module Arrolio
5
5
  # Hanging-indent list. Each item has a marker column (fixed
6
6
  # width) and a body column (wraps under body, not marker).
7
7
  class ListFlowable < Flowable
8
- attr_reader :items, :kind, :marker_width, :body_indent
8
+ attr_reader :items, :kind, :marker_width, :body_indent,
9
+ :marker_indent, :item_spacing
9
10
 
10
11
  def initialize(items, kind: :bullet, style: Style::Definition.new,
11
- marker_width: 18.0, body_indent: 6.0)
12
+ marker_width: 18.0, body_indent: 6.0,
13
+ marker_indent: 0.0, item_spacing: 0.0)
12
14
  super(style: style)
13
15
  @kind = kind.to_sym
14
16
  @marker_width = marker_width.to_f
15
17
  @body_indent = body_indent.to_f
18
+ @marker_indent = marker_indent.to_f
19
+ @item_spacing = item_spacing.to_f
16
20
  @items = items
17
21
  freeze
18
22
  end
19
23
 
20
24
  def height(width, context = nil)
21
- @items.sum do |item|
25
+ body_h = @items.sum do |item|
22
26
  _, body_flowables = item
23
- body_width = width - @marker_width - @body_indent
24
- body_flowables.sum { |f| f.height(body_width, context) }
27
+ body_flowables.sum { |f| f.height(body_width(width), context) }
25
28
  end
29
+ body_h + ([@items.length - 1, 0].max * @item_spacing)
30
+ end
31
+
32
+ # What must stay with a preceding keep-with-next flowable:
33
+ # the first item — the lead-in paragraph keeps with the
34
+ # list's first line, not the whole list.
35
+ def min_keep_height(width, context = nil)
36
+ _, body_flowables = @items.first
37
+ return 0.0 unless body_flowables&.any?
38
+
39
+ body_flowables.sum { |f| f.height(body_width(width), context) }
26
40
  end
27
41
 
28
42
  def emit(x, y, width, context = nil)
29
43
  boxes = []
30
44
  consumed = 0.0
31
45
  cursor = y
32
- body_x = x + @marker_width + @body_indent
33
- body_width = width - @marker_width - @body_indent
46
+ marker_x = x + @marker_indent
47
+ body_x = x + @marker_indent + @marker_width + @body_indent
34
48
 
35
49
  @items.each_with_index do |item, idx|
36
50
  marker_text, body_flowables = item
37
51
  marker = marker_text || default_marker(idx)
38
52
 
39
53
  if marker.is_a?(String)
40
- if marker.empty?
41
- # no marker to draw
42
- else
43
- mboxes, = marker_flowable(marker, x, cursor, width, context).emit(x, cursor, width, context)
54
+ unless marker.empty?
55
+ mboxes, = marker_flowable(marker, marker_x, cursor, width, context)
56
+ .emit(marker_x, cursor, width, context)
44
57
  boxes.concat(mboxes)
45
58
  end
46
59
  elsif marker
47
60
  # marker is a Flowable (e.g. NoteFlowable passes a TextFlowable)
48
- mboxes, = marker.emit(x, cursor, width, context)
61
+ mboxes, = marker.emit(marker_x, cursor, width, context)
49
62
  boxes.concat(mboxes)
50
63
  end
51
64
 
65
+ item_h = 0.0
52
66
  body_flowables.each do |f|
53
- fh = f.height(body_width, context)
54
- fboxes, = f.emit(body_x, cursor, body_width, context)
67
+ fh = f.height(body_width(width), context)
68
+ fboxes, = f.emit(body_x, cursor, body_width(width), context)
55
69
  boxes.concat(fboxes)
56
70
  cursor -= fh
57
- consumed += fh
71
+ item_h += fh
58
72
  end
73
+
74
+ if idx < @items.length - 1 && @item_spacing.positive?
75
+ cursor -= @item_spacing
76
+ item_h += @item_spacing
77
+ end
78
+ consumed += item_h
59
79
  end
60
80
 
61
81
  [boxes, consumed]
@@ -72,7 +92,7 @@ module Arrolio
72
92
 
73
93
  @items.each do |item|
74
94
  _, body_flowables = item
75
- item_h = body_flowables.sum { |f| f.height(width - @marker_width - @body_indent, context) }
95
+ item_h = body_flowables.sum { |f| f.height(body_width(width), context) }
76
96
  if used + item_h <= remaining_height || head_items.empty?
77
97
  head_items << item
78
98
  used += item_h
@@ -86,20 +106,28 @@ module Arrolio
86
106
  else
87
107
  self.class.new(head_items, kind: @kind, style: @style,
88
108
  marker_width: @marker_width,
89
- body_indent: @body_indent)
109
+ body_indent: @body_indent,
110
+ marker_indent: @marker_indent,
111
+ item_spacing: @item_spacing)
90
112
  end
91
113
  tail = if tail_items.empty?
92
114
  nil
93
115
  else
94
116
  self.class.new(tail_items, kind: @kind, style: @style,
95
117
  marker_width: @marker_width,
96
- body_indent: @body_indent)
118
+ body_indent: @body_indent,
119
+ marker_indent: @marker_indent,
120
+ item_spacing: @item_spacing)
97
121
  end
98
122
  [head, tail]
99
123
  end
100
124
 
101
125
  private
102
126
 
127
+ def body_width(width)
128
+ width - @marker_indent - @marker_width - @body_indent
129
+ end
130
+
103
131
  def default_marker(idx)
104
132
  case @kind
105
133
  when :ordered then "#{idx + 1}."
@@ -2,17 +2,92 @@
2
2
 
3
3
  module Arrolio
4
4
  module Flowables
5
- # Note / example callout: italic label in marker column, body
6
- # indented. Reuses ListFlowable's hanging-indent infrastructure.
5
+ # Note / example callout. Two label layouts:
6
+ #
7
+ # - :hanging (notes) — italic label in the marker column, body
8
+ # wraps on the same first line (hanging indent). Implemented on
9
+ # ListFlowable's hanging-indent infrastructure.
10
+ # - :block (examples) — the label is a heading on its own line
11
+ # at the column edge ("Example:"), body flows below it indented
12
+ # by +body_indent+. Emitted sequentially: the label line's
13
+ # height is part of the flow, so page breaking sees the whole
14
+ # block.
7
15
  class NoteFlowable < ListFlowable
16
+ BLOCK_LABEL_GAP = 6.0
17
+
18
+ attr_reader :label_mode
19
+
8
20
  def initialize(label_text, body_flowables, style: Style::Definition.new,
9
- marker_width: 22.0, body_indent: 6.0)
10
- label_style = style.with(font_size: style.font_size)
11
- marker_run = InlineRun.new("#{label_text} ", style: label_style)
12
- marker_flow = TextFlowable.new([marker_run], style: label_style)
13
- super([[marker_flow, body_flowables]],
14
- kind: :note, style: style,
15
- marker_width: marker_width, body_indent: body_indent)
21
+ marker_width: 22.0, body_indent: 6.0, label_style: nil,
22
+ label_mode: :hanging)
23
+ @label_mode = label_mode.to_sym
24
+ effective_label_style = label_style || style.with(font_size: style.font_size)
25
+ if @label_mode == :block
26
+ @block_label = TextFlowable.new(
27
+ [InlineRun.new("#{label_text}:", style: effective_label_style)],
28
+ style: effective_label_style
29
+ )
30
+ @block_body = Array(body_flowables)
31
+ @block_indent = body_indent.to_f
32
+ super([[nil, []]], kind: :note, style: style)
33
+ else
34
+ marker_run = InlineRun.new("#{label_text} ", style: effective_label_style)
35
+ marker_flow = TextFlowable.new([marker_run], style: effective_label_style)
36
+ super([[marker_flow, Array(body_flowables)]],
37
+ kind: :note, style: style,
38
+ marker_width: marker_width, body_indent: body_indent)
39
+ end
40
+ end
41
+
42
+ def height(width, context = nil)
43
+ return block_height(width, context) if @label_mode == :block
44
+
45
+ super
46
+ end
47
+
48
+ def emit(x, y, width, context = nil)
49
+ return block_emit(x, y, width, context) if @label_mode == :block
50
+
51
+ super
52
+ end
53
+
54
+ # Notes stay intact across page breaks: if the whole note does
55
+ # not fit, it moves to the next page. ListFlowable#do_split is
56
+ # not usable here — it rebuilds via self.class.new(items, ...)
57
+ # which does not match this constructor's signature.
58
+ def do_split(width, remaining_height, context = nil)
59
+ total = height(width, context)
60
+ if total <= remaining_height
61
+ [self, nil]
62
+ else
63
+ [nil, self]
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def block_height(width, context = nil)
70
+ @block_label.height(width, context) + BLOCK_LABEL_GAP +
71
+ @block_body.sum { |f| f.height(width - @block_indent, context) }
72
+ end
73
+
74
+ def block_emit(x, y, width, context = nil)
75
+ boxes = []
76
+ consumed = 0.0
77
+ cursor = y
78
+
79
+ label_boxes, label_h = @block_label.emit(x, cursor, width, context)
80
+ boxes.concat(label_boxes)
81
+ cursor -= label_h + BLOCK_LABEL_GAP
82
+ consumed += label_h + BLOCK_LABEL_GAP
83
+
84
+ @block_body.each do |flow|
85
+ fboxes, fh = flow.emit(x + @block_indent, cursor, width - @block_indent, context)
86
+ boxes.concat(fboxes)
87
+ cursor -= fh
88
+ consumed += fh
89
+ end
90
+ [boxes, consumed]
16
91
  end
17
92
  end
18
93
  end
@@ -7,17 +7,21 @@ module Arrolio
7
7
  # until the next PageSequenceStart.
8
8
  class PageSequenceStart < Flowable
9
9
  attr_reader :role, :header_template, :footer_template,
10
- :header_align, :footer_align, :initial_page_number
10
+ :header_align, :footer_align, :initial_page_number,
11
+ :title_template
11
12
 
13
+ # +header_align+ nil means "no flavor opinion" — the engine
14
+ # picks left/right from page parity (XSL-FO odd/even headers).
12
15
  def initialize(role:, header_template: nil, footer_template: nil,
13
- header_align: :right, footer_align: :center,
14
- initial_page_number: nil)
16
+ header_align: nil, footer_align: :center,
17
+ initial_page_number: nil, title_template: nil)
15
18
  @role = role.to_sym
16
19
  @header_template = header_template
17
20
  @footer_template = footer_template
18
- @header_align = header_align.to_sym
21
+ @header_align = header_align&.to_sym
19
22
  @footer_align = footer_align.to_sym
20
23
  @initial_page_number = initial_page_number
24
+ @title_template = title_template
21
25
  end
22
26
 
23
27
  def height(_width, _context = nil) = 0.0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Absolute-positioned container for cover-page layouts. Renders
6
+ # its children at a fixed (top, left) offset within the page,
7
+ # regardless of the normal top-down flow.
8
+ #
9
+ # The engine treats PositionedBlock as zero-height in the flow
10
+ # (consumed = 0), so the next flowable continues at the same
11
+ # y-coordinate. The block's children are placed at page-relative
12
+ # coordinates computed from +top+ and +left+.
13
+ #
14
+ # Used by cover layouts to place the title border-box, the
15
+ # organisation footer block, and similar non-flow regions.
16
+ class PositionedBlock < Flowable
17
+ attr_reader :children, :top, :left, :width, :height_value
18
+
19
+ # +children:+ Array of Flowable to render at the fixed position.
20
+ # +top:+ Distance from the page's top edge (points).
21
+ # +left:+ Distance from the page's left edge (points).
22
+ # +width:+ Width of the block (points).
23
+ # +height:+ Height of the block (points); content overflowing
24
+ # is clipped at render time.
25
+ def initialize(children:, top:, left:, width:, height:,
26
+ style: Style::Definition.new)
27
+ super(style: style)
28
+ @children = Array(children)
29
+ @top = top.to_f
30
+ @left = left.to_f
31
+ @width = width.to_f
32
+ @height_value = height.to_f
33
+ end
34
+
35
+ def height(_width, _context = nil)
36
+ 0.0
37
+ end
38
+
39
+ def emit(x, y, _region_width, context = nil)
40
+ page_top = y + (y).abs # approximate page-top from current y
41
+ block_y = page_top - @top - @height_value
42
+ block_x = x + @left
43
+ boxes = []
44
+ cursor = block_y + @height_value
45
+ @children.each do |child|
46
+ child_boxes, consumed = child.emit(block_x, cursor, @width, context)
47
+ boxes.concat(child_boxes)
48
+ cursor -= consumed
49
+ end
50
+ # The block consumes zero flow height — siblings continue at y.
51
+ [boxes, 0.0]
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Flowables
5
+ # Text rendered at a fixed rotation around its baseline. Used
6
+ # for cover layouts where vertical text runs along the page
7
+ # edge (some flavors put the docidentifier + edition rotated
8
+ # 90° on the left margin).
9
+ #
10
+ # +angle+ is in degrees, clockwise from horizontal. Common
11
+ # values: 90 (text reads top-to-bottom), 270 (bottom-to-top).
12
+ #
13
+ # The flowable consumes zero vertical flow space (it's
14
+ # absolutely positioned via PositionedBlock or similar) so
15
+ # siblings continue normally.
16
+ class RotatedText < Flowable
17
+ attr_reader :text, :angle
18
+
19
+ def initialize(text, style: Style::Definition.new, angle: 90)
20
+ super(style: style)
21
+ @text = text.to_s
22
+ @angle = angle.to_f
23
+ end
24
+
25
+ def height(_width, _context = nil)
26
+ GlyphMeasurer.new(font_name: @style.font_name)
27
+ .line_height(font_size: @style.font_size,
28
+ line_spacing: @style.line_spacing)
29
+ end
30
+
31
+ def emit(x, y, width, _context = nil)
32
+ line_h = height(width)
33
+ measurer = GlyphMeasurer.new(font_name: @style.font_name)
34
+ text_w = measurer.width_of_string(@text, font_size: @style.font_size)
35
+
36
+ box = Output::PlacedBox.new(
37
+ x: x, y: y - line_h,
38
+ width: text_w, height: line_h,
39
+ kind: :rotated_text,
40
+ data: {
41
+ text: @text,
42
+ angle: @angle,
43
+ line_height: line_h,
44
+ style: @style
45
+ }
46
+ )
47
+ [[box], 0.0]
48
+ end
49
+ end
50
+ end
51
+ end