idml 0.6.2 → 0.7.0
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 +4 -4
- data/Gemfile +1 -1
- data/TODO.pdf/100-character-level-styling.md +11 -1
- data/TODO.pdf/104-table-schema-completion.md +6 -1
- data/TODO.pdf/105-whole-package-round-trip.md +4 -1
- data/TODO.pdf/94-text-frame-vertical-layout.md +5 -1
- data/TODO.pdf/95-paragraph-spacing-indents.md +2 -1
- data/TODO.pdf/96-text-frame-insets-actual.md +3 -1
- data/TODO.pdf/97-table-cell-rendering-fidelity.md +4 -1
- data/TODO.pdf/98-rowspan-columnspan.md +4 -1
- data/TODO.pdf/99-table-column-widths.md +5 -1
- data/idml.gemspec +1 -1
- data/lib/idml/elements/table.rb +48 -4
- data/lib/idml/render/character_style.rb +100 -0
- data/lib/idml/render/renderers/table_renderer.rb +188 -27
- data/lib/idml/render/renderers/text_frame_renderer.rb +185 -59
- data/lib/idml/render/style_resolver.rb +89 -7
- data/lib/idml/render.rb +1 -0
- data/lib/idml/text_engine/vertical_layout.rb +50 -45
- data/lib/idml/text_engine.rb +3 -2
- data/lib/idml/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f40610210789018b1fbe0625c1541cfe3611169d0004543891ebb9efd6c74ea
|
|
4
|
+
data.tar.gz: 6998d5c66bd56eed147dfb88d60a602be32fee2adb9b9fa9f08413af4cb2103f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02c03bf2c81b2ebb81b04ae4b413b25d484adf806b1d8c7c0768e557323bca7c30de46d5174cff27c90e2275f6f89070ac5f981df567cb20867e5c17382b0c42
|
|
7
|
+
data.tar.gz: 642103e8e48d2b5b52a4b669d7e09329f5706e4f165f4da8de6df51ef38125686ca75db28de194a974f94cdc9273a193c76a653bf8727b3a16f6b5ac2a9c5f3c
|
data/Gemfile
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# TODO PDF 100: Character-level styling (underline, strikethrough, caps, color)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — new `Render::CharacterStyle` helper applies
|
|
4
|
+
per-run fill color, draws underline / strike-through rules, and
|
|
5
|
+
handles Capitalization (AllCaps/SmallCaps → uppercase) and Position
|
|
6
|
+
(Superscript/Subscript → shrunk font + baseline offset).
|
|
7
|
+
TextFrameRenderer.emit_line wraps canvas.text via CharacterStyle.
|
|
8
|
+
StyledRun extended with underline_offset/weight, strike_through_offset/weight
|
|
9
|
+
attrs from CSR.
|
|
10
|
+
|
|
11
|
+
Open follow-ups (lower priority): glyph horizontal/vertical scaling
|
|
12
|
+
(needs pdfrb text-matrix support), tracking (needs Shaper-level
|
|
13
|
+
width adjustment), text stroke outline.
|
|
4
14
|
|
|
5
15
|
## Problem
|
|
6
16
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# TODO PDF 104: Schema-faithful Table model completion
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: PARTIAL — added the rendering-relevant subset (column_count,
|
|
4
|
+
single_column_width, header/body/footer row counts, table_direction,
|
|
5
|
+
outer-border strokes). The remaining ~150 schema attributes (alternating
|
|
6
|
+
stroke rows, column rules, table-flavor bits) aren't needed for
|
|
7
|
+
rendering and would be pure model bloat. Add incrementally as use
|
|
8
|
+
cases demand.
|
|
4
9
|
|
|
5
10
|
## Problem
|
|
6
11
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# TODO PDF 105: Whole-package round-trip suite (byte-equivalent)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — `spec/idml/round_trip_all_fixtures_spec.rb` already
|
|
4
|
+
runs byte-equivalent round-trip for every fixture .idml (28 examples,
|
|
5
|
+
all passing). `spec/idml/package_spec.rb:140` verifies the mimetype
|
|
6
|
+
entry is stored and first.
|
|
4
7
|
|
|
5
8
|
## Problem
|
|
6
9
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# TODO PDF 94: TextFrameRenderer should delegate to VerticalLayout
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — VerticalLayout refactored to block-style API
|
|
4
|
+
(`layout_block` returns positioned lines + next cursor y);
|
|
5
|
+
TextFrameRenderer now uses StyleResolver.extract_paragraphs +
|
|
6
|
+
VerticalLayout.layout_block for proper vertical stacking across
|
|
7
|
+
paragraphs and runs.
|
|
4
8
|
|
|
5
9
|
## Problem
|
|
6
10
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# TODO PDF 95: Paragraph spacing and indents (SpaceBefore, SpaceAfter, FirstLineIndent)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — Paragraph struct carries these from PSR; renderer
|
|
4
|
+
passes them to VerticalLayout.layout_block.
|
|
4
5
|
|
|
5
6
|
## Problem
|
|
6
7
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# TODO PDF 96: TextFramePreference insets honored in layout
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — layout_frame helper reads TextFramePreference
|
|
4
|
+
insets and passes them to the Frame struct; VerticalLayout honors
|
|
5
|
+
them in positioning and wrap_width.
|
|
4
6
|
|
|
5
7
|
## Problem
|
|
6
8
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# TODO PDF 97: Table cell rendering — fill, stroke, insets, vertical justification
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — cell fill_color renders as background; per-cell
|
|
4
|
+
TextTopInset/Left/Bottom/Right honored (was hardcoded 4.0);
|
|
5
|
+
VerticalJustification positions text Top/Bottom/Center; per-cell
|
|
6
|
+
PSR walks typed runs with their own font/size.
|
|
4
7
|
|
|
5
8
|
## Problem
|
|
6
9
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# TODO PDF 98: RowSpan / ColumnSpan (merged cells) in TableRenderer
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — SchemaLayout reads cell.column_span and cell.row_span,
|
|
4
|
+
multiplies width/height by the span, tracks a coverage set, and skips
|
|
5
|
+
positions covered by a spanning cell. Spanning cell renders once at
|
|
6
|
+
its top-left corner.
|
|
4
7
|
|
|
5
8
|
## Problem
|
|
6
9
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# TODO PDF 99: Table column widths from ColumnAttributes
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — Table now models `column_count` and
|
|
4
|
+
`single_column_width`; SchemaLayout uses SingleColumnWidth when
|
|
5
|
+
declared, else falls back to even division. (IDML has no separate
|
|
6
|
+
ColumnAttributes element — column widths live on Table directly
|
|
7
|
+
when uniform.)
|
|
4
8
|
|
|
5
9
|
## Problem
|
|
6
10
|
|
data/idml.gemspec
CHANGED
|
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
|
|
|
31
31
|
spec.add_dependency "bigdecimal"
|
|
32
32
|
spec.add_dependency "logger"
|
|
33
33
|
spec.add_dependency "lutaml-model", "~> 0.8.18"
|
|
34
|
-
spec.add_dependency "pdfrb", "~> 0.
|
|
34
|
+
spec.add_dependency "pdfrb", "~> 0.7"
|
|
35
35
|
spec.add_dependency "rubyzip"
|
|
36
36
|
spec.add_dependency "thor"
|
|
37
37
|
|
data/lib/idml/elements/table.rb
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Elements
|
|
5
|
-
# `<Table>` — a table page item. Carries geometry
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# `<Table>` — a table page item. Carries geometry, body/header
|
|
6
|
+
# row counts, column layout, and border styling. Two child
|
|
7
|
+
# collections hold the table's content:
|
|
8
|
+
#
|
|
9
|
+
# - `row` — schema-faithful `<Row>` siblings (real IDML).
|
|
10
|
+
# - `cell` — schema-faithful `<Cell>` siblings (real IDML).
|
|
11
|
+
# - `table_row` — legacy nested `<TableRow><TableCell>` form
|
|
12
|
+
# (synthetic test fixture only; see TODO 84).
|
|
13
|
+
#
|
|
14
|
+
# The renderer auto-detects which layout is present.
|
|
9
15
|
class Table < Lutaml::Model::Serializable
|
|
10
16
|
attribute :self_attr, :string
|
|
11
17
|
attribute :name, :string
|
|
@@ -15,6 +21,28 @@ module Idml
|
|
|
15
21
|
attribute :fill_color, :string
|
|
16
22
|
attribute :stroke_color, :string
|
|
17
23
|
attribute :stroke_weight, :float
|
|
24
|
+
|
|
25
|
+
# Schema-faithful layout attributes (from Table_Object in
|
|
26
|
+
# Stories/Story.rnc). The renderer consults these to compute
|
|
27
|
+
# column widths and to clip body/header/footer regions.
|
|
28
|
+
attribute :header_row_count, :integer
|
|
29
|
+
attribute :footer_row_count, :integer
|
|
30
|
+
attribute :body_row_count, :integer
|
|
31
|
+
attribute :column_count, :integer
|
|
32
|
+
attribute :single_column_width, :float
|
|
33
|
+
attribute :table_direction, :string
|
|
34
|
+
|
|
35
|
+
# Outer-border strokes (TopBorderStrokeWeight, etc.). Useful
|
|
36
|
+
# when the renderer draws the table's bounding rectangle.
|
|
37
|
+
attribute :top_border_stroke_weight, :float
|
|
38
|
+
attribute :top_border_stroke_color, :string
|
|
39
|
+
attribute :left_border_stroke_weight, :float
|
|
40
|
+
attribute :left_border_stroke_color, :string
|
|
41
|
+
attribute :bottom_border_stroke_weight, :float
|
|
42
|
+
attribute :bottom_border_stroke_color, :string
|
|
43
|
+
attribute :right_border_stroke_weight, :float
|
|
44
|
+
attribute :right_border_stroke_color, :string
|
|
45
|
+
|
|
18
46
|
attribute :table_row, Idml::Elements::TableRow, collection: true
|
|
19
47
|
attribute :cell, Idml::Elements::Cell, collection: true
|
|
20
48
|
attribute :row, Idml::Elements::Row, collection: true
|
|
@@ -30,6 +58,22 @@ module Idml
|
|
|
30
58
|
map_attribute "FillColor", to: :fill_color
|
|
31
59
|
map_attribute "StrokeColor", to: :stroke_color
|
|
32
60
|
map_attribute "StrokeWeight", to: :stroke_weight
|
|
61
|
+
map_attribute "HeaderRowCount", to: :header_row_count
|
|
62
|
+
map_attribute "FooterRowCount", to: :footer_row_count
|
|
63
|
+
map_attribute "BodyRowCount", to: :body_row_count
|
|
64
|
+
map_attribute "ColumnCount", to: :column_count
|
|
65
|
+
map_attribute "SingleColumnWidth", to: :single_column_width
|
|
66
|
+
map_attribute "TableDirection", to: :table_direction
|
|
67
|
+
map_attribute "TopBorderStrokeWeight", to: :top_border_stroke_weight
|
|
68
|
+
map_attribute "TopBorderStrokeColor", to: :top_border_stroke_color
|
|
69
|
+
map_attribute "LeftBorderStrokeWeight", to: :left_border_stroke_weight
|
|
70
|
+
map_attribute "LeftBorderStrokeColor", to: :left_border_stroke_color
|
|
71
|
+
map_attribute "BottomBorderStrokeWeight",
|
|
72
|
+
to: :bottom_border_stroke_weight
|
|
73
|
+
map_attribute "BottomBorderStrokeColor",
|
|
74
|
+
to: :bottom_border_stroke_color
|
|
75
|
+
map_attribute "RightBorderStrokeWeight", to: :right_border_stroke_weight
|
|
76
|
+
map_attribute "RightBorderStrokeColor", to: :right_border_stroke_color
|
|
33
77
|
map_element "TableRow", to: :table_row
|
|
34
78
|
map_element "Cell", to: :cell
|
|
35
79
|
map_element "Row", to: :row
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Applies character-level styling from a `StyleResolver::StyledRun`
|
|
6
|
+
# around a text-emitting block. Centralizes the IDML → PDF mapping
|
|
7
|
+
# for run-level visual properties (text color, underline rule,
|
|
8
|
+
# strike-through rule, capitalization, super/subscript position).
|
|
9
|
+
#
|
|
10
|
+
# Used by `TextFrameRenderer` so character-level effects live in
|
|
11
|
+
# one place (DRY). The block typically calls `canvas.text` (or
|
|
12
|
+
# `canvas.text_rich`); this helper sets the surrounding graphics
|
|
13
|
+
# state and emits the rule lines after the block returns.
|
|
14
|
+
module CharacterStyle
|
|
15
|
+
UNDERLINE_THICKNESS_FACTOR = 0.05
|
|
16
|
+
UNDERLINE_DROP_FACTOR = 0.10
|
|
17
|
+
STRIKE_THICKNESS_FACTOR = 0.05
|
|
18
|
+
STRIKE_RISE_FACTOR = 0.25
|
|
19
|
+
|
|
20
|
+
# Yields with the run's fill color applied, then draws underline
|
|
21
|
+
# and/or strike-through rules above/below the text baseline.
|
|
22
|
+
# `x`, `y`, `width`, `size` describe the line's geometry so the
|
|
23
|
+
# rules can be positioned correctly.
|
|
24
|
+
def self.apply(canvas, run, context, x:, y:, width:, size:)
|
|
25
|
+
apply_fill_color(canvas, run, context)
|
|
26
|
+
yield
|
|
27
|
+
draw_underline(canvas, run, x, y, width, size)
|
|
28
|
+
draw_strike_through(canvas, run, x, y, width, size)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Transforms the text per the CSR's Capitalization attribute.
|
|
32
|
+
# AllCaps/SmallCaps → uppercase (true small-caps would require
|
|
33
|
+
# an OpenType feature; deferred). Other values pass through.
|
|
34
|
+
def self.transform_text(text, capitalization)
|
|
35
|
+
return text unless %w[AllCaps SmallCaps].include?(capitalization)
|
|
36
|
+
|
|
37
|
+
text.upcase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns `[font_size, baseline_offset]` for the CSR's Position
|
|
41
|
+
# attribute. Superscript/subscript shrink the font and shift
|
|
42
|
+
# the baseline. Normal / nil returns the size unchanged.
|
|
43
|
+
def self.position_scale(position, font_size)
|
|
44
|
+
case position
|
|
45
|
+
when "Superscript" then [font_size * 0.583, font_size * 0.333]
|
|
46
|
+
when "Subscript" then [font_size * 0.583, -font_size * 0.0833]
|
|
47
|
+
else [font_size, 0.0]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.apply_fill_color(canvas, run, context)
|
|
52
|
+
return unless run.fill_color
|
|
53
|
+
return if run.fill_color == "Color/None"
|
|
54
|
+
|
|
55
|
+
color = context.color_resolver&.resolve(run.fill_color)
|
|
56
|
+
return unless color
|
|
57
|
+
|
|
58
|
+
canvas.fill_color(ColorHelper.to_canvas(color))
|
|
59
|
+
end
|
|
60
|
+
private_class_method :apply_fill_color
|
|
61
|
+
|
|
62
|
+
def self.draw_underline(canvas, run, x, y, width, size)
|
|
63
|
+
return unless run.underline
|
|
64
|
+
|
|
65
|
+
thickness = rule_thickness(run.underline_weight, size,
|
|
66
|
+
UNDERLINE_THICKNESS_FACTOR)
|
|
67
|
+
drop = rule_offset(run.underline_offset, size,
|
|
68
|
+
UNDERLINE_DROP_FACTOR)
|
|
69
|
+
canvas.rectangle(x, y - drop, width, thickness)
|
|
70
|
+
canvas.fill
|
|
71
|
+
end
|
|
72
|
+
private_class_method :draw_underline
|
|
73
|
+
|
|
74
|
+
def self.draw_strike_through(canvas, run, x, y, width, size)
|
|
75
|
+
return unless run.strike_thru
|
|
76
|
+
|
|
77
|
+
thickness = rule_thickness(run.strike_through_weight, size,
|
|
78
|
+
STRIKE_THICKNESS_FACTOR)
|
|
79
|
+
rise = rule_offset(run.strike_through_offset, size,
|
|
80
|
+
STRIKE_RISE_FACTOR)
|
|
81
|
+
canvas.rectangle(x, y + rise - thickness, width, thickness)
|
|
82
|
+
canvas.fill
|
|
83
|
+
end
|
|
84
|
+
private_class_method :draw_strike_through
|
|
85
|
+
|
|
86
|
+
def self.rule_thickness(declared, size, factor)
|
|
87
|
+
declared&.positive? ? declared : (size * factor)
|
|
88
|
+
end
|
|
89
|
+
private_class_method :rule_thickness
|
|
90
|
+
|
|
91
|
+
def self.rule_offset(declared, size, factor)
|
|
92
|
+
return size * factor unless declared
|
|
93
|
+
return size * factor if declared.zero?
|
|
94
|
+
|
|
95
|
+
declared
|
|
96
|
+
end
|
|
97
|
+
private_class_method :rule_offset
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -4,9 +4,8 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
6
|
# Renders an IDML Table. Draws the cell grid via rectangle
|
|
7
|
-
# ops, then renders inline text in each cell
|
|
8
|
-
#
|
|
9
|
-
# rectangles.
|
|
7
|
+
# ops, then renders inline text in each cell. Cells with no
|
|
8
|
+
# text render as empty rectangles.
|
|
10
9
|
#
|
|
11
10
|
# Two table layouts are supported:
|
|
12
11
|
#
|
|
@@ -19,9 +18,20 @@ module Idml
|
|
|
19
18
|
# `table_cell` collection. Nested structure.
|
|
20
19
|
#
|
|
21
20
|
# The renderer auto-detects which layout is present.
|
|
21
|
+
#
|
|
22
|
+
# Honors per-cell:
|
|
23
|
+
# - `fill_color` / `fill_tint` — background fill before stroke.
|
|
24
|
+
# - `top_inset` / `left_inset` / `bottom_inset` / `right_inset`
|
|
25
|
+
# — text insets (replaces fixed `INSET = 4.0`).
|
|
26
|
+
# - `vertical_justification` — Top / Center / Bottom / Justify.
|
|
27
|
+
# - `paragraph_style_range` → typed runs (per-run font + size).
|
|
28
|
+
#
|
|
29
|
+
# Honors per-table:
|
|
30
|
+
# - `single_column_width` — overrides the even-division default
|
|
31
|
+
# for column widths.
|
|
22
32
|
class TableRenderer
|
|
23
33
|
DEFAULT_SIZE = 10.0
|
|
24
|
-
|
|
34
|
+
DEFAULT_INSET = 4.0
|
|
25
35
|
|
|
26
36
|
def self.render(canvas, context)
|
|
27
37
|
table = context.item
|
|
@@ -57,17 +67,42 @@ module Idml
|
|
|
57
67
|
|
|
58
68
|
# Real IDML: Table > {Cell, Row} siblings. Cell Name is
|
|
59
69
|
# "col:row". Row count from `row` collection; column count
|
|
60
|
-
# derived from max col + 1
|
|
70
|
+
# derived from `column_count` attribute or max col + 1.
|
|
61
71
|
def self.render_schema_faithful(canvas, table, box, context)
|
|
62
72
|
layout = SchemaLayout.new(table: table, box: box)
|
|
63
73
|
layout.each_cell do |cell_x, cell_y, cell_w, cell_h, cell|
|
|
64
|
-
canvas
|
|
65
|
-
|
|
74
|
+
render_cell_background(canvas, cell, cell_x, cell_y, cell_w, cell_h,
|
|
75
|
+
context)
|
|
76
|
+
render_cell_border(canvas, cell_x, cell_y, cell_w, cell_h)
|
|
66
77
|
render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
|
|
67
78
|
end
|
|
68
79
|
end
|
|
69
80
|
private_class_method :render_schema_faithful
|
|
70
81
|
|
|
82
|
+
def self.render_cell_background(canvas, cell, x, y, w, h, context)
|
|
83
|
+
color = cell_fill_color(cell, context)
|
|
84
|
+
return unless color
|
|
85
|
+
|
|
86
|
+
canvas.fill_color(ColorHelper.to_canvas(color))
|
|
87
|
+
canvas.rectangle(x, y, w, h)
|
|
88
|
+
canvas.fill
|
|
89
|
+
end
|
|
90
|
+
private_class_method :render_cell_background
|
|
91
|
+
|
|
92
|
+
def self.render_cell_border(canvas, x, y, w, h)
|
|
93
|
+
canvas.rectangle(x, y, w, h)
|
|
94
|
+
canvas.stroke
|
|
95
|
+
end
|
|
96
|
+
private_class_method :render_cell_border
|
|
97
|
+
|
|
98
|
+
def self.cell_fill_color(cell, context)
|
|
99
|
+
return nil unless cell.fill_color
|
|
100
|
+
return nil if cell.fill_color == "Color/None"
|
|
101
|
+
|
|
102
|
+
context.color_resolver&.resolve(cell.fill_color)
|
|
103
|
+
end
|
|
104
|
+
private_class_method :cell_fill_color
|
|
105
|
+
|
|
71
106
|
# Legacy: Table > TableRow > TableCell nested.
|
|
72
107
|
def self.render_legacy(canvas, table, box, context)
|
|
73
108
|
return if table.table_row.empty?
|
|
@@ -93,33 +128,122 @@ module Idml
|
|
|
93
128
|
private_class_method :render_legacy
|
|
94
129
|
|
|
95
130
|
def self.render_cell_text(canvas, cell, x, y, height, context)
|
|
131
|
+
runs = cell_text_runs(cell, context)
|
|
132
|
+
return if runs.empty?
|
|
133
|
+
|
|
134
|
+
insets = cell_insets(cell)
|
|
135
|
+
baseline = vertical_baseline(y, height, insets, cell)
|
|
136
|
+
canvas.text_rich(runs, at: [x + insets[:left], baseline])
|
|
137
|
+
end
|
|
138
|
+
private_class_method :render_cell_text
|
|
139
|
+
|
|
140
|
+
# Builds pdfrb runs from the cell's typed PSR/CSR children.
|
|
141
|
+
# Walks ParagraphStyleRange → CharacterStyleRange → Content
|
|
142
|
+
# so each run carries its own font + size (per-cell CSR
|
|
143
|
+
# styling). Falls back to a single DEFAULT_SIZE run when
|
|
144
|
+
# the cell has no typed paragraphs (legacy TableCell).
|
|
145
|
+
def self.cell_text_runs(cell, context)
|
|
146
|
+
psr = typed_paragraphs(cell)
|
|
147
|
+
return default_text_runs(cell, context) if psr.empty?
|
|
148
|
+
|
|
149
|
+
psr.flat_map { |paragraph| paragraph_runs(paragraph, context) }
|
|
150
|
+
end
|
|
151
|
+
private_class_method :cell_text_runs
|
|
152
|
+
|
|
153
|
+
def self.paragraph_runs(paragraph, context)
|
|
154
|
+
paragraph.character_style_range.filter_map do |csr|
|
|
155
|
+
text = csr.text_content
|
|
156
|
+
next if text.nil? || text.empty?
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
text: text,
|
|
160
|
+
font: context.font_ps_name,
|
|
161
|
+
size: csr.point_size || DEFAULT_SIZE,
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
private_class_method :paragraph_runs
|
|
166
|
+
|
|
167
|
+
# Real IDML `Cell` exposes paragraph_style_range; the legacy
|
|
168
|
+
# `TableCell` (synthetic fixture only) doesn't, so we treat
|
|
169
|
+
# it as having no typed paragraphs and fall through to the
|
|
170
|
+
# default_text_runs path.
|
|
171
|
+
def self.typed_paragraphs(cell)
|
|
172
|
+
return [] unless cell.is_a?(Idml::Elements::Cell)
|
|
173
|
+
|
|
174
|
+
cell.paragraph_style_range
|
|
175
|
+
end
|
|
176
|
+
private_class_method :typed_paragraphs
|
|
177
|
+
|
|
178
|
+
def self.default_text_runs(cell, context)
|
|
96
179
|
text = cell.text_content
|
|
97
|
-
return if text.nil? || text.empty?
|
|
180
|
+
return [] if text.nil? || text.empty?
|
|
98
181
|
|
|
99
|
-
|
|
182
|
+
[{
|
|
100
183
|
text: text,
|
|
101
184
|
font: context.font_ps_name,
|
|
102
185
|
size: DEFAULT_SIZE,
|
|
103
186
|
}]
|
|
104
|
-
baseline = y + (height / 2)
|
|
105
|
-
canvas.text_rich(runs, at: [x + INSET, baseline])
|
|
106
187
|
end
|
|
107
|
-
private_class_method :
|
|
188
|
+
private_class_method :default_text_runs
|
|
189
|
+
|
|
190
|
+
# Cell insets: prefer typed TextTopInset/Left/Bottom/Right,
|
|
191
|
+
# fall back to DEFAULT_INSET for any that are missing.
|
|
192
|
+
def self.cell_insets(cell)
|
|
193
|
+
{
|
|
194
|
+
top: cell.top_inset || DEFAULT_INSET,
|
|
195
|
+
bottom: cell.bottom_inset || DEFAULT_INSET,
|
|
196
|
+
left: cell.left_inset || DEFAULT_INSET,
|
|
197
|
+
right: cell.right_inset || DEFAULT_INSET,
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
private_class_method :cell_insets
|
|
201
|
+
|
|
202
|
+
# Vertical baseline for the cell's first text line, per the
|
|
203
|
+
# cell's VerticalJustification. IDML values: TopAlign,
|
|
204
|
+
# CenterAlign, BottomAlign, JustifyAlign. Default: CenterAlign
|
|
205
|
+
# (legacy behavior).
|
|
206
|
+
def self.vertical_baseline(y, height, insets, cell)
|
|
207
|
+
case cell.vertical_justification
|
|
208
|
+
when "TopAlign"
|
|
209
|
+
y + height - insets[:top]
|
|
210
|
+
when "BottomAlign"
|
|
211
|
+
y + insets[:bottom]
|
|
212
|
+
else # CenterAlign, JustifyAlign, nil
|
|
213
|
+
y + (height / 2)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
private_class_method :vertical_baseline
|
|
108
217
|
|
|
109
218
|
# Computes per-cell rects for the schema-faithful layout.
|
|
110
219
|
# Row heights come from `Row#single_row_height` when present,
|
|
111
220
|
# else fall back to evenly-divided table height. Column
|
|
112
|
-
# widths
|
|
113
|
-
#
|
|
221
|
+
# widths come from `Table#single_column_width` when present
|
|
222
|
+
# (uniform columns only — per-column widths would require
|
|
223
|
+
# ColumnAttributes, which IDML doesn't model separately),
|
|
224
|
+
# else divide evenly.
|
|
225
|
+
#
|
|
226
|
+
# Honors `Cell#column_span` and `Cell#row_span`: a spanning
|
|
227
|
+
# cell covers multiple grid positions and is rendered once
|
|
228
|
+
# at its top-left corner with the merged rect. Cells covered
|
|
229
|
+
# by another cell's span are skipped.
|
|
114
230
|
SchemaLayout = Struct.new(:table, :box, keyword_init: true) do
|
|
115
231
|
def each_cell
|
|
232
|
+
covered = Set.new
|
|
233
|
+
|
|
116
234
|
row_count.times do |row_idx|
|
|
117
235
|
col_count.times do |col_idx|
|
|
236
|
+
next if covered.include?([col_idx, row_idx])
|
|
237
|
+
|
|
118
238
|
cell = cell_at(col_idx, row_idx)
|
|
119
239
|
next unless cell
|
|
120
240
|
|
|
121
|
-
|
|
122
|
-
|
|
241
|
+
col_span = cell.column_span || 1
|
|
242
|
+
row_span = cell.row_span || 1
|
|
243
|
+
mark_covered(covered, col_idx, row_idx, col_span, row_span)
|
|
244
|
+
|
|
245
|
+
yield cell_x(col_idx), cell_y(row_idx, col_span, row_span),
|
|
246
|
+
cell_w(col_span), cell_h(row_idx, row_span), cell
|
|
123
247
|
end
|
|
124
248
|
end
|
|
125
249
|
end
|
|
@@ -130,11 +254,16 @@ module Idml
|
|
|
130
254
|
|
|
131
255
|
def col_count
|
|
132
256
|
@col_count ||= begin
|
|
133
|
-
|
|
134
|
-
|
|
257
|
+
declared = table.column_count
|
|
258
|
+
declared&.positive? ? declared : derived_col_count
|
|
135
259
|
end
|
|
136
260
|
end
|
|
137
261
|
|
|
262
|
+
def derived_col_count
|
|
263
|
+
max_col = cells.filter_map(&:col_row).map(&:first).max || 0
|
|
264
|
+
max_col + 1
|
|
265
|
+
end
|
|
266
|
+
|
|
138
267
|
def rows
|
|
139
268
|
table.row
|
|
140
269
|
end
|
|
@@ -147,20 +276,52 @@ module Idml
|
|
|
147
276
|
cells.find { |c| c.col_row == [col, row] }
|
|
148
277
|
end
|
|
149
278
|
|
|
279
|
+
def mark_covered(covered, col, row, col_span, row_span)
|
|
280
|
+
col_span.times do |dc|
|
|
281
|
+
row_span.times do |dr|
|
|
282
|
+
covered << [col + dc, row + dr]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
private :mark_covered
|
|
287
|
+
|
|
150
288
|
def cell_x(col)
|
|
151
|
-
box[:x] + (col *
|
|
289
|
+
box[:x] + (col * per_col_width)
|
|
152
290
|
end
|
|
153
291
|
|
|
154
|
-
|
|
155
|
-
|
|
292
|
+
# PDF-coordinate bottom y of the cell's rectangle. For a
|
|
293
|
+
# row_spanning cell, this is the bottom edge of the last
|
|
294
|
+
# spanned row. PDF rectangle takes (x, y_bottom, w, h).
|
|
295
|
+
def cell_y(row, _col_span, row_span)
|
|
296
|
+
last_row = row + row_span - 1
|
|
297
|
+
box[:y] + total_height - cumulative_height(last_row + 1)
|
|
156
298
|
end
|
|
157
299
|
|
|
158
|
-
|
|
159
|
-
|
|
300
|
+
# Width covered by `span` columns starting at the cell's col.
|
|
301
|
+
def cell_w(span)
|
|
302
|
+
per_col_width * (span || 1)
|
|
160
303
|
end
|
|
161
304
|
|
|
162
|
-
|
|
163
|
-
|
|
305
|
+
# Total height covered by `span` rows starting at `row_idx`.
|
|
306
|
+
def cell_h(row_idx, span)
|
|
307
|
+
(span || 1).times.sum { |i| row_height(row_idx + i) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def per_col_width
|
|
311
|
+
return @per_col_width if @per_col_width
|
|
312
|
+
|
|
313
|
+
declared = table.single_column_width
|
|
314
|
+
@per_col_width = if declared&.positive?
|
|
315
|
+
declared
|
|
316
|
+
else
|
|
317
|
+
box[:width] / [col_count, 1].max
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def row_height(idx)
|
|
322
|
+
return 0.0 unless idx.between?(0, row_count - 1)
|
|
323
|
+
|
|
324
|
+
row_heights[idx] || (box[:height] / [row_count, 1].max)
|
|
164
325
|
end
|
|
165
326
|
|
|
166
327
|
def row_heights
|
|
@@ -171,11 +332,11 @@ module Idml
|
|
|
171
332
|
end
|
|
172
333
|
|
|
173
334
|
def total_height
|
|
174
|
-
@total_height ||= row_count.times.sum { |i|
|
|
335
|
+
@total_height ||= row_count.times.sum { |i| row_height(i) }
|
|
175
336
|
end
|
|
176
337
|
|
|
177
338
|
def cumulative_height(up_to_row)
|
|
178
|
-
up_to_row.times.sum { |i|
|
|
339
|
+
up_to_row.times.sum { |i| row_height(i) }
|
|
179
340
|
end
|
|
180
341
|
end
|
|
181
342
|
private_constant :SchemaLayout
|
|
@@ -3,11 +3,26 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
-
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas.
|
|
7
|
+
#
|
|
8
|
+
# Pipeline:
|
|
9
|
+
#
|
|
10
|
+
# 1. Resolve the linked Story via Package#story_by_id.
|
|
11
|
+
# 2. Extract `Paragraph`s via StyleResolver (carries PSR
|
|
12
|
+
# attributes like SpaceBefore, FirstLineIndent, AutoLeading
|
|
13
|
+
# alongside the CSR runs).
|
|
14
|
+
# 3. Build a layout `Frame` from the TextFrame's geometric
|
|
15
|
+
# bounds plus TextFramePreference insets.
|
|
16
|
+
# 4. For each paragraph: shape glyphs (Shaper), wrap to the
|
|
17
|
+
# inset/indent-adjusted width (LineBreaker), align (Justifier),
|
|
18
|
+
# position vertically (VerticalLayout), and emit one
|
|
19
|
+
# `canvas.text` per line. Tracks the y cursor across
|
|
20
|
+
# paragraphs and runs so text flows top-to-bottom.
|
|
21
|
+
# 5. Records each positioned line in PositionTracker so
|
|
22
|
+
# HyperlinkEmitter can compute precise per-source link rects.
|
|
23
|
+
#
|
|
24
|
+
# Falls back to a single `canvas.text_rich` per frame when no
|
|
25
|
+
# FontMetrics is available (no shaper → no wrap → rough output).
|
|
11
26
|
class TextFrameRenderer
|
|
12
27
|
DEFAULT_SIZE = 12.0
|
|
13
28
|
LEADING_FACTOR = 1.2
|
|
@@ -23,10 +38,10 @@ module Idml
|
|
|
23
38
|
box = frame_box(frame, context.page_height)
|
|
24
39
|
render_inline_tables(canvas, story, box, context)
|
|
25
40
|
|
|
26
|
-
|
|
27
|
-
return if
|
|
41
|
+
paragraphs = StyleResolver.extract_paragraphs(story)
|
|
42
|
+
return if paragraphs.empty?
|
|
28
43
|
|
|
29
|
-
render_text(canvas,
|
|
44
|
+
render_text(canvas, paragraphs, context, box)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
# Discovers Tables inlined in the story (Story > PSR > CSR >
|
|
@@ -55,12 +70,12 @@ module Idml
|
|
|
55
70
|
end
|
|
56
71
|
private_class_method :tables_in_story
|
|
57
72
|
|
|
58
|
-
def self.render_text(canvas,
|
|
73
|
+
def self.render_text(canvas, paragraphs, context, box)
|
|
59
74
|
font = context.font_metrics
|
|
60
75
|
if font
|
|
61
|
-
engine_render(canvas,
|
|
76
|
+
engine_render(canvas, paragraphs, context, box, font)
|
|
62
77
|
else
|
|
63
|
-
simple_render(canvas,
|
|
78
|
+
simple_render(canvas, paragraphs, context, box)
|
|
64
79
|
end
|
|
65
80
|
end
|
|
66
81
|
private_class_method :render_text
|
|
@@ -75,58 +90,169 @@ module Idml
|
|
|
75
90
|
end
|
|
76
91
|
private_class_method :frame_box
|
|
77
92
|
|
|
78
|
-
|
|
93
|
+
# Builds a layout Frame from the TextFrame's PDF-placement
|
|
94
|
+
# box plus any TextFramePreference insets. Inset values fall
|
|
95
|
+
# back to 0 when the preference is absent or doesn't declare
|
|
96
|
+
# them.
|
|
97
|
+
def self.layout_frame(frame_item, box)
|
|
98
|
+
pref = frame_item.text_frame_preference&.first
|
|
99
|
+
TextEngine::Frame.new(
|
|
100
|
+
x: box[:x],
|
|
101
|
+
y: box[:y],
|
|
102
|
+
width: box[:width],
|
|
103
|
+
height: box[:height],
|
|
104
|
+
inset_top: pref&.inset_top,
|
|
105
|
+
inset_bottom: pref&.inset_bottom,
|
|
106
|
+
inset_left: pref&.inset_left,
|
|
107
|
+
inset_right: pref&.inset_right,
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
private_class_method :layout_frame
|
|
111
|
+
|
|
112
|
+
# Walks paragraphs and emits one `canvas.text` per line via
|
|
113
|
+
# the Shaper → LineBreaker → Justifier → VerticalLayout
|
|
114
|
+
# pipeline. Cursor y descends monotonically across
|
|
115
|
+
# paragraphs so text flows top-to-bottom (the previous
|
|
116
|
+
# implementation reset every run to the frame top).
|
|
117
|
+
def self.engine_render(canvas, paragraphs, context, box, font)
|
|
118
|
+
layout_frame = layout_frame(context.item, box)
|
|
119
|
+
cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
|
|
120
|
+
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
79
121
|
char_cursor = 0
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
122
|
+
|
|
123
|
+
paragraphs.each do |paragraph|
|
|
124
|
+
break if cursor_y < bottom_limit
|
|
125
|
+
|
|
126
|
+
cursor_y, char_cursor = render_paragraph(
|
|
127
|
+
canvas, paragraph, context, layout_frame, font,
|
|
128
|
+
cursor_y, bottom_limit, char_cursor
|
|
83
129
|
)
|
|
84
130
|
end
|
|
85
131
|
end
|
|
86
132
|
private_class_method :engine_render
|
|
87
133
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
def self.render_paragraph(canvas, paragraph, context, layout_frame,
|
|
135
|
+
font, cursor_y, bottom_limit, char_cursor)
|
|
136
|
+
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
137
|
+
layout_frame, paragraph.right_indent || 0
|
|
138
|
+
)
|
|
139
|
+
para_attrs = paragraph_attrs(paragraph)
|
|
140
|
+
cursor_y, char_cursor = render_runs(
|
|
141
|
+
canvas, paragraph, context, layout_frame, font,
|
|
142
|
+
wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
|
|
143
|
+
)
|
|
144
|
+
[cursor_y - para_attrs[:space_after], char_cursor]
|
|
145
|
+
end
|
|
146
|
+
private_class_method :render_paragraph
|
|
147
|
+
|
|
148
|
+
def self.paragraph_attrs(paragraph)
|
|
149
|
+
{
|
|
150
|
+
space_before: paragraph.space_before || 0,
|
|
151
|
+
space_after: paragraph.space_after || 0,
|
|
152
|
+
first_line_indent: paragraph.first_line_indent || 0,
|
|
153
|
+
left_indent: paragraph.left_indent || 0,
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
private_class_method :paragraph_attrs
|
|
157
|
+
|
|
158
|
+
def self.render_runs(canvas, paragraph, context, layout_frame, font,
|
|
159
|
+
wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
160
|
+
para_attrs)
|
|
161
|
+
space_before = para_attrs[:space_before]
|
|
162
|
+
paragraph.runs.each do |run|
|
|
163
|
+
break if cursor_y < bottom_limit
|
|
164
|
+
|
|
165
|
+
positioned, next_y = layout_run(
|
|
166
|
+
canvas, run, paragraph, context, layout_frame, font,
|
|
167
|
+
wrap_width, cursor_y, space_before,
|
|
168
|
+
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
169
|
+
char_cursor
|
|
170
|
+
)
|
|
171
|
+
char_cursor += positioned.length
|
|
172
|
+
cursor_y = next_y
|
|
173
|
+
space_before = 0
|
|
174
|
+
end
|
|
175
|
+
[cursor_y, char_cursor]
|
|
176
|
+
end
|
|
177
|
+
private_class_method :render_runs
|
|
178
|
+
|
|
179
|
+
# Shapes one run's text, wraps to the inset/indent-adjusted
|
|
180
|
+
# width, justifies per paragraph alignment, and asks
|
|
181
|
+
# VerticalLayout to position the lines. Emits each line via
|
|
182
|
+
# `canvas.text` and records positions for hyperlink tracking.
|
|
183
|
+
# Returns `[positioned_lines, next_y]`.
|
|
184
|
+
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
185
|
+
font, wrap_width, cursor_y, space_before,
|
|
186
|
+
first_line_indent, left_indent,
|
|
187
|
+
char_cursor)
|
|
188
|
+
size = run.point_size || DEFAULT_SIZE
|
|
98
189
|
glyphs = TextEngine::Shaper.shape(
|
|
99
190
|
text: run.text, font: font, size: size,
|
|
100
191
|
)
|
|
101
192
|
lines = TextEngine::LineBreaker.break(
|
|
102
|
-
glyphs: glyphs, frame_width:
|
|
193
|
+
glyphs: glyphs, frame_width: wrap_width,
|
|
103
194
|
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
line_y = start_y - (idx * size * LEADING_FACTOR)
|
|
110
|
-
break if line_y < box[:y]
|
|
111
|
-
|
|
112
|
-
TextEngine::Justifier.justify(line: line,
|
|
113
|
-
frame_width: box[:width],
|
|
114
|
-
alignment: alignment)
|
|
115
|
-
line_x = box[:x] + line.x_offset
|
|
116
|
-
line_width = line.width
|
|
117
|
-
canvas.text(line_text(line),
|
|
118
|
-
at: [line_x, line_y],
|
|
119
|
-
font: font_for_run(run, context),
|
|
120
|
-
size: size)
|
|
121
|
-
record_position(context, line, line_x, line_y, line_width, size,
|
|
122
|
-
cursor)
|
|
123
|
-
cursor += line.glyphs.length
|
|
195
|
+
lines.each do |line|
|
|
196
|
+
TextEngine::Justifier.justify(
|
|
197
|
+
line: line, frame_width: wrap_width,
|
|
198
|
+
alignment: paragraph.alignment || :left
|
|
199
|
+
)
|
|
124
200
|
end
|
|
125
|
-
|
|
201
|
+
leading = leading_for(paragraph, size)
|
|
202
|
+
|
|
203
|
+
positioned, next_y = TextEngine::VerticalLayout.layout_block(
|
|
204
|
+
lines: lines,
|
|
205
|
+
frame: layout_frame,
|
|
206
|
+
font_size: size,
|
|
207
|
+
leading: leading,
|
|
208
|
+
cursor_y: cursor_y,
|
|
209
|
+
space_before: space_before,
|
|
210
|
+
first_line_indent: first_line_indent,
|
|
211
|
+
left_indent: left_indent,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
215
|
+
positioned.each do |line|
|
|
216
|
+
break if line.y < bottom_limit
|
|
217
|
+
|
|
218
|
+
emit_line(canvas, line, run, context, size)
|
|
219
|
+
record_position(context, line, size, char_cursor)
|
|
220
|
+
end
|
|
221
|
+
[positioned, next_y]
|
|
126
222
|
end
|
|
127
|
-
private_class_method :
|
|
223
|
+
private_class_method :layout_run
|
|
128
224
|
|
|
129
|
-
|
|
225
|
+
# Emits one positioned line: applies character-level styling
|
|
226
|
+
# (fill color, capitalization, position) via CharacterStyle,
|
|
227
|
+
# then draws underline/strike-through rules after the text.
|
|
228
|
+
def self.emit_line(canvas, line, run, context, size)
|
|
229
|
+
text = CharacterStyle.transform_text(line_text(line),
|
|
230
|
+
run.capitalization)
|
|
231
|
+
scaled_size, baseline_offset = CharacterStyle.position_scale(
|
|
232
|
+
run.position, size
|
|
233
|
+
)
|
|
234
|
+
CharacterStyle.apply(canvas, run, context,
|
|
235
|
+
x: line.x, y: line.y + baseline_offset,
|
|
236
|
+
width: line.width, size: scaled_size) do
|
|
237
|
+
canvas.text(
|
|
238
|
+
text,
|
|
239
|
+
at: [line.x, line.y + baseline_offset],
|
|
240
|
+
font: font_for_run(run, context),
|
|
241
|
+
size: scaled_size,
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
private_class_method :emit_line
|
|
246
|
+
|
|
247
|
+
def self.leading_for(paragraph, size)
|
|
248
|
+
explicit = paragraph.auto_leading
|
|
249
|
+
return size * LEADING_FACTOR unless explicit&.positive?
|
|
250
|
+
|
|
251
|
+
size * explicit
|
|
252
|
+
end
|
|
253
|
+
private_class_method :leading_for
|
|
254
|
+
|
|
255
|
+
def self.record_position(context, line, height, cursor)
|
|
130
256
|
tracker = context.position_tracker
|
|
131
257
|
return unless tracker
|
|
132
258
|
return unless context.item&.self_attr
|
|
@@ -135,9 +261,9 @@ module Idml
|
|
|
135
261
|
tracker.add(context.item.self_attr,
|
|
136
262
|
start_char: cursor,
|
|
137
263
|
end_char: cursor + glyph_count,
|
|
138
|
-
x: x,
|
|
139
|
-
y: y,
|
|
140
|
-
width: width,
|
|
264
|
+
x: line.x,
|
|
265
|
+
y: line.y,
|
|
266
|
+
width: line.width,
|
|
141
267
|
height: height)
|
|
142
268
|
end
|
|
143
269
|
private_class_method :record_position
|
|
@@ -146,7 +272,7 @@ module Idml
|
|
|
146
272
|
# run's `applied_font` (family name from CSR's AppliedFont)
|
|
147
273
|
# against the document's font_map. Falls back to the document
|
|
148
274
|
# default when no per-run font is specified or the family
|
|
149
|
-
# isn registered.
|
|
275
|
+
# isn't registered.
|
|
150
276
|
def self.font_for_run(run, context)
|
|
151
277
|
family = run.applied_font
|
|
152
278
|
return context.font_ps_name unless family
|
|
@@ -163,12 +289,12 @@ module Idml
|
|
|
163
289
|
|
|
164
290
|
# Fallback when no metrics are available: emit all runs as
|
|
165
291
|
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return if runs_for.empty?
|
|
292
|
+
def self.simple_render(canvas, paragraphs, context, box)
|
|
293
|
+
runs = paragraphs.flat_map(&:runs)
|
|
294
|
+
return if runs.empty?
|
|
170
295
|
|
|
171
|
-
|
|
296
|
+
runs_for = build_rich_runs(runs, context)
|
|
297
|
+
first_size = runs.first.point_size || DEFAULT_SIZE
|
|
172
298
|
canvas.text_rich(
|
|
173
299
|
runs_for,
|
|
174
300
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
@@ -181,7 +307,7 @@ module Idml
|
|
|
181
307
|
{
|
|
182
308
|
text: run.text,
|
|
183
309
|
font: font_for_run(run, context),
|
|
184
|
-
size: run.point_size,
|
|
310
|
+
size: run.point_size || DEFAULT_SIZE,
|
|
185
311
|
}
|
|
186
312
|
end
|
|
187
313
|
end
|
|
@@ -2,15 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
|
-
# Extracts styled text
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# paragraph
|
|
5
|
+
# Extracts styled text from an IDML Story in two granularities:
|
|
6
|
+
#
|
|
7
|
+
# 1. `extract_runs` — flat list of `StyledRun` (character-level
|
|
8
|
+
# attributes only). Useful when paragraph boundaries don't
|
|
9
|
+
# matter (e.g., simple-render fallback).
|
|
10
|
+
# 2. `extract_paragraphs` — list of `Paragraph` structs, each
|
|
11
|
+
# carrying paragraph-level attributes (spacing, indents,
|
|
12
|
+
# leading) plus its runs. Used by the layout engine so the
|
|
13
|
+
# renderer can apply SpaceBefore / FirstLineIndent / etc.
|
|
14
|
+
#
|
|
15
|
+
# Both methods read the same source (PSR → CSR → Content) so the
|
|
16
|
+
# text content is identical; only the grouping and attribute
|
|
17
|
+
# propagation differ.
|
|
9
18
|
class StyleResolver
|
|
19
|
+
# Character-level attributes carried per run. Defaults are
|
|
20
|
+
# applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
|
|
21
|
+
# itself stores only what the CSR declares.
|
|
10
22
|
StyledRun = Struct.new(
|
|
11
|
-
:text,
|
|
12
|
-
:
|
|
13
|
-
|
|
23
|
+
:text,
|
|
24
|
+
:font_style,
|
|
25
|
+
:point_size,
|
|
26
|
+
:fill_color,
|
|
27
|
+
:fill_tint,
|
|
28
|
+
:applied_font,
|
|
29
|
+
:alignment,
|
|
30
|
+
:tracking,
|
|
31
|
+
:capitalization,
|
|
32
|
+
:position,
|
|
33
|
+
:underline,
|
|
34
|
+
:underline_offset,
|
|
35
|
+
:underline_weight,
|
|
36
|
+
:strike_thru,
|
|
37
|
+
:strike_through_offset,
|
|
38
|
+
:strike_through_weight,
|
|
39
|
+
:horizontal_scale,
|
|
40
|
+
:vertical_scale,
|
|
41
|
+
:baseline_shift,
|
|
42
|
+
keyword_init: true,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Paragraph-level attributes carried alongside a run list.
|
|
46
|
+
# Each attribute comes from the owning PSR and is nil when the
|
|
47
|
+
# PSR doesn't declare it.
|
|
48
|
+
Paragraph = Struct.new(
|
|
49
|
+
:runs,
|
|
50
|
+
:alignment,
|
|
51
|
+
:space_before,
|
|
52
|
+
:space_after,
|
|
53
|
+
:first_line_indent,
|
|
54
|
+
:left_indent,
|
|
55
|
+
:right_indent,
|
|
56
|
+
:auto_leading,
|
|
57
|
+
keyword_init: true,
|
|
14
58
|
)
|
|
15
59
|
|
|
16
60
|
DEFAULT_POINT_SIZE = 12.0
|
|
@@ -28,6 +72,8 @@ module Idml
|
|
|
28
72
|
"ToBinding" => :left,
|
|
29
73
|
}.freeze
|
|
30
74
|
|
|
75
|
+
# Returns the flat run list for a story. Each run carries only
|
|
76
|
+
# character-level attributes. Paragraph boundaries are lost.
|
|
31
77
|
def self.extract_runs(story)
|
|
32
78
|
return [] unless story&.inner
|
|
33
79
|
|
|
@@ -36,6 +82,30 @@ module Idml
|
|
|
36
82
|
end
|
|
37
83
|
end
|
|
38
84
|
|
|
85
|
+
# Returns the paragraph list for a story. Each paragraph groups
|
|
86
|
+
# its runs and carries the paragraph-level attributes from the
|
|
87
|
+
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
88
|
+
# skipped.
|
|
89
|
+
def self.extract_paragraphs(story)
|
|
90
|
+
return [] unless story&.inner
|
|
91
|
+
|
|
92
|
+
story.inner.paragraph_style_range.filter_map do |psr|
|
|
93
|
+
runs = csr_runs(psr)
|
|
94
|
+
next if runs.empty?
|
|
95
|
+
|
|
96
|
+
Paragraph.new(
|
|
97
|
+
runs: runs,
|
|
98
|
+
alignment: runs.first.alignment,
|
|
99
|
+
space_before: psr.space_before,
|
|
100
|
+
space_after: psr.space_after,
|
|
101
|
+
first_line_indent: psr.first_line_indent,
|
|
102
|
+
left_indent: psr.left_indent,
|
|
103
|
+
right_indent: psr.right_indent,
|
|
104
|
+
auto_leading: psr.auto_leading,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
39
109
|
def self.csr_runs(psr)
|
|
40
110
|
psr.character_style_range.filter_map do |csr|
|
|
41
111
|
text = csr.text_content
|
|
@@ -49,6 +119,18 @@ module Idml
|
|
|
49
119
|
fill_tint: csr.fill_tint,
|
|
50
120
|
applied_font: csr.applied_font,
|
|
51
121
|
alignment: alignment_for(csr),
|
|
122
|
+
tracking: csr.tracking,
|
|
123
|
+
capitalization: csr.capitalization,
|
|
124
|
+
position: csr.position,
|
|
125
|
+
underline: csr.underline,
|
|
126
|
+
underline_offset: csr.underline_offset,
|
|
127
|
+
underline_weight: csr.underline_weight,
|
|
128
|
+
strike_thru: csr.strike_thru,
|
|
129
|
+
strike_through_offset: csr.strike_through_offset,
|
|
130
|
+
strike_through_weight: csr.strike_through_weight,
|
|
131
|
+
horizontal_scale: csr.horizontal_scale,
|
|
132
|
+
vertical_scale: csr.vertical_scale,
|
|
133
|
+
baseline_shift: csr.baseline_shift,
|
|
52
134
|
)
|
|
53
135
|
end
|
|
54
136
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Idml
|
|
|
13
13
|
autoload :GradientResolver, "#{__dir__}/render/gradient_resolver"
|
|
14
14
|
autoload :Blending, "#{__dir__}/render/blending"
|
|
15
15
|
autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
|
|
16
|
+
autoload :CharacterStyle, "#{__dir__}/render/character_style"
|
|
16
17
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
17
18
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
18
19
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
@@ -2,63 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module TextEngine
|
|
5
|
-
#
|
|
6
|
-
|
|
5
|
+
# Geometry of a text frame as seen by the layout engine: outer
|
|
6
|
+
# bounds plus the four inset margins. Inset values are nil when
|
|
7
|
+
# the TextFramePreference doesn't declare them.
|
|
8
|
+
Frame = Struct.new(
|
|
9
|
+
:x, :y, :width, :height,
|
|
10
|
+
:inset_top, :inset_bottom, :inset_left, :inset_right,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
)
|
|
7
13
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# left/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
:inset_top, :inset_bottom,
|
|
14
|
-
:inset_left, :inset_right)
|
|
14
|
+
# A line that's been positioned absolutely within a frame.
|
|
15
|
+
# `glyphs` is the original ShapedGlyph array (carrying widths);
|
|
16
|
+
# `x`, `y` is the left edge / baseline; `width` is the line's
|
|
17
|
+
# natural width after justification.
|
|
18
|
+
PositionedLine = Struct.new(:glyphs, :x, :y, :width, keyword_init: true)
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# Positions lines vertically within a text frame. The layout is
|
|
21
|
+
# block-oriented: each call to `layout_block` returns the
|
|
22
|
+
# positioned lines for one block (paragraph, run, etc.) and the
|
|
23
|
+
# y cursor for the next block, so callers can stack paragraphs
|
|
24
|
+
# without re-deriving cursor math.
|
|
25
|
+
class VerticalLayout
|
|
26
|
+
DEFAULT_LEADING_FACTOR = 1.2
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
# Position `lines` starting at `cursor_y` and walking downward.
|
|
29
|
+
# `cursor_y` is the top of the available space; the first line
|
|
30
|
+
# sits one `leading` below it. Returns `[positioned_lines, next_y]`
|
|
31
|
+
# where `next_y` is where the next block should start (i.e. below
|
|
32
|
+
# this block's last line plus `space_after`).
|
|
33
|
+
def self.layout_block(lines:, frame:, font_size:, cursor_y:, leading: nil, space_before: 0, space_after: 0,
|
|
34
|
+
first_line_indent: 0, left_indent: 0)
|
|
35
|
+
effective_leading = leading || (font_size * DEFAULT_LEADING_FACTOR)
|
|
36
|
+
y = cursor_y - space_before
|
|
37
|
+
positioned = []
|
|
33
38
|
|
|
34
|
-
def layout(lines)
|
|
35
|
-
result = []
|
|
36
|
-
y = top_y - @space_before
|
|
37
39
|
lines.each_with_index do |line, idx|
|
|
38
|
-
y -=
|
|
39
|
-
x = frame_left +
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
current_x,
|
|
45
|
-
y,
|
|
46
|
-
@font_size)
|
|
47
|
-
current_x += glyph.width
|
|
48
|
-
end
|
|
40
|
+
y -= effective_leading
|
|
41
|
+
x = frame_left(frame) + left_indent +
|
|
42
|
+
(idx.zero? ? first_line_indent : 0) + line.x_offset.to_f
|
|
43
|
+
positioned << PositionedLine.new(
|
|
44
|
+
glyphs: line.glyphs, x: x, y: y, width: line.width,
|
|
45
|
+
)
|
|
49
46
|
end
|
|
50
|
-
|
|
47
|
+
|
|
48
|
+
[positioned, y - space_after]
|
|
51
49
|
end
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
# Bottom y of the frame's text area (frame bottom edge + bottom
|
|
52
|
+
# inset). Lines whose y falls below this are clipped.
|
|
53
|
+
def self.bottom_limit(frame)
|
|
54
|
+
frame.y + (frame.inset_bottom || 0)
|
|
55
|
+
end
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
# Effective wrap width after subtracting insets and indents.
|
|
58
|
+
def self.wrap_width(frame, right_indent = 0)
|
|
59
|
+
width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
|
|
60
|
+
width - right_indent
|
|
57
61
|
end
|
|
58
62
|
|
|
59
|
-
def frame_left
|
|
60
|
-
|
|
63
|
+
def self.frame_left(frame)
|
|
64
|
+
frame.x + (frame.inset_left || 0)
|
|
61
65
|
end
|
|
66
|
+
private_class_method :frame_left
|
|
62
67
|
end
|
|
63
68
|
end
|
|
64
69
|
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
# Pure-Ruby text layout engine. Takes styled text runs + frame
|
|
5
|
-
# geometry and produces positioned
|
|
5
|
+
# geometry and produces positioned lines using font metrics from
|
|
6
6
|
# .ttf/.otf files. IDML-agnostic — works on any styled text.
|
|
7
7
|
module TextEngine
|
|
8
8
|
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
@@ -12,7 +12,8 @@ module Idml
|
|
|
12
12
|
autoload :Line, "#{__dir__}/text_engine/line_breaker"
|
|
13
13
|
autoload :Justifier, "#{__dir__}/text_engine/justifier"
|
|
14
14
|
autoload :VerticalLayout, "#{__dir__}/text_engine/vertical_layout"
|
|
15
|
-
autoload :
|
|
15
|
+
autoload :Frame, "#{__dir__}/text_engine/vertical_layout"
|
|
16
|
+
autoload :PositionedLine, "#{__dir__}/text_engine/vertical_layout"
|
|
16
17
|
autoload :CjkLayout, "#{__dir__}/text_engine/cjk_layout"
|
|
17
18
|
end
|
|
18
19
|
end
|
data/lib/idml/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -58,14 +58,14 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '0.
|
|
61
|
+
version: '0.7'
|
|
62
62
|
type: :runtime
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '0.
|
|
68
|
+
version: '0.7'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: rubyzip
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -421,6 +421,7 @@ files:
|
|
|
421
421
|
- lib/idml/render.rb
|
|
422
422
|
- lib/idml/render/blending.rb
|
|
423
423
|
- lib/idml/render/bookmark_resolver.rb
|
|
424
|
+
- lib/idml/render/character_style.rb
|
|
424
425
|
- lib/idml/render/color_helper.rb
|
|
425
426
|
- lib/idml/render/color_resolver.rb
|
|
426
427
|
- lib/idml/render/font_reference_resolver.rb
|