idml 0.6.1 → 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/.github/workflows/release.yml +1 -3
- data/.gitignore +1 -0
- data/Gemfile +2 -1
- data/TODO.pdf/01-font-metrics.md +6 -0
- data/TODO.pdf/02-font-resolver.md +5 -0
- data/TODO.pdf/03-text-shaper.md +4 -0
- data/TODO.pdf/04-line-breaker-justifier.md +4 -0
- data/TODO.pdf/05-vertical-layout.md +4 -0
- data/TODO.pdf/06-pdf-color-operators.md +5 -0
- data/TODO.pdf/07-pdf-path-operators.md +5 -0
- data/TODO.pdf/08-pdf-text-operators.md +5 -0
- data/TODO.pdf/09-image-embedding.md +5 -0
- data/TODO.pdf/10-spread-renderer.md +5 -0
- data/TODO.pdf/100-character-level-styling.md +71 -0
- data/TODO.pdf/101-paragraph-rules-borders.md +38 -0
- data/TODO.pdf/102-anchored-objects.md +41 -0
- data/TODO.pdf/103-drop-caps.md +33 -0
- data/TODO.pdf/104-table-schema-completion.md +49 -0
- data/TODO.pdf/105-whole-package-round-trip.md +48 -0
- data/TODO.pdf/106-page-item-type-coverage.md +62 -0
- data/TODO.pdf/107-image-rendering-unification.md +42 -0
- data/TODO.pdf/11-font-embedding.md +6 -0
- data/TODO.pdf/12-idml-to-pdf-pipeline.md +6 -0
- data/TODO.pdf/13-cjk-text-layout.md +5 -0
- data/TODO.pdf/14-page-item-element-models.md +5 -0
- data/TODO.pdf/15-wire-spread-children.md +5 -0
- data/TODO.pdf/16-typed-model-pipeline.md +5 -0
- data/TODO.pdf/17-shape-rendering.md +5 -0
- data/TODO.pdf/18-text-rendering.md +5 -0
- data/TODO.pdf/19-page-dimensions.md +4 -0
- data/TODO.pdf/20-png-image-support.md +4 -0
- data/TODO.pdf/21-coordinate-transform.md +5 -0
- data/TODO.pdf/22-color-resolution.md +5 -0
- data/TODO.pdf/23-fontmetrics-ps-name-fix.md +5 -0
- data/TODO.pdf/24-antipattern-coverage.md +5 -0
- data/TODO.pdf/25-cli-render.md +4 -0
- data/TODO.pdf/26-renderer-registry.md +5 -0
- data/TODO.pdf/27-text-engine-integration.md +6 -0
- data/TODO.pdf/28-path-geometry-models.md +6 -0
- data/TODO.pdf/29-character-style-extraction.md +5 -0
- data/TODO.pdf/30-shape-geometry-rendering.md +6 -0
- data/TODO.pdf/31-stroke-rendering.md +6 -0
- data/TODO.pdf/32-master-spread-rendering.md +6 -0
- data/TODO.pdf/33-font-resolution-from-idml.md +7 -0
- data/TODO.pdf/34-story-threading.md +5 -0
- data/TODO.pdf/35-pdf-metadata.md +6 -0
- data/TODO.pdf/36-performance-caching.md +5 -0
- data/TODO.pdf/37-round-trip-specs.md +5 -0
- data/TODO.pdf/38-pdf-output-validation.md +5 -0
- data/TODO.pdf/41-graphic-line-rendering.md +5 -0
- data/TODO.pdf/42-group-transform-application.md +5 -0
- data/TODO.pdf/43-text-frame-insets.md +6 -0
- data/TODO.pdf/44-layer-support.md +6 -0
- data/TODO.pdf/45-image-clipping.md +4 -0
- data/TODO.pdf/46-spread-to-page-separation.md +5 -0
- data/TODO.pdf/47-pdf-bookmarks.md +6 -0
- data/TODO.pdf/48-table-rendering.md +7 -0
- data/TODO.pdf/49-gradient-fill-rendering.md +6 -0
- data/TODO.pdf/50-pdf-a-compliance.md +6 -0
- data/TODO.pdf/68-radial-gradient-shadings.md +4 -0
- data/TODO.pdf/69-transparency-blend-modes.md +6 -0
- data/TODO.pdf/94-text-frame-vertical-layout.md +67 -0
- data/TODO.pdf/95-paragraph-spacing-indents.md +51 -0
- data/TODO.pdf/96-text-frame-insets-actual.md +41 -0
- data/TODO.pdf/97-table-cell-rendering-fidelity.md +65 -0
- data/TODO.pdf/98-rowspan-columnspan.md +38 -0
- data/TODO.pdf/99-table-column-widths.md +44 -0
- data/TODO.pdf/README.md +6 -9
- data/idml.gemspec +2 -1
- data/lib/idml/elements/table.rb +48 -4
- data/lib/idml/render/character_style.rb +100 -0
- data/lib/idml/render/font_setup.rb +9 -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 +40 -8
- data/Gemfile.lock +0 -235
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# TODO PDF 99: Table column widths from ColumnAttributes
|
|
2
|
+
|
|
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.)
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
`TableRenderer::SchemaLayout#cell_w` divides the table width evenly
|
|
12
|
+
across all columns:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
def cell_w
|
|
16
|
+
box[:width] / [col_count, 1].max
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Real IDML documents declare per-column widths via the
|
|
21
|
+
`ColumnAttributes` child of `Table` (with `SingleColumnWidth`
|
|
22
|
+
attributes). The renderer ignores these, producing visually wrong
|
|
23
|
+
tables whenever columns aren't equal-width.
|
|
24
|
+
|
|
25
|
+
## What needs to happen
|
|
26
|
+
|
|
27
|
+
1. Verify `Elements::Table` exposes the `column_attributes`
|
|
28
|
+
collection (add it if missing).
|
|
29
|
+
2. `SchemaLayout` reads each `ColumnAttributes#single_column_width`
|
|
30
|
+
and computes a cumulative column-x table.
|
|
31
|
+
3. `cell_x(col)` looks up the cumulative position; `cell_w(col)`
|
|
32
|
+
uses the actual width for that column.
|
|
33
|
+
4. Falls back to even division when `ColumnAttributes` is absent.
|
|
34
|
+
|
|
35
|
+
## Acceptance criteria
|
|
36
|
+
|
|
37
|
+
- [ ] Table with two columns, widths 100pt and 200pt, renders cells
|
|
38
|
+
at the correct x positions and widths.
|
|
39
|
+
- [ ] Table without ColumnAttributes renders as today (even division).
|
|
40
|
+
- [ ] Spanning cells (ColumnSpan) cover the correct cumulative width.
|
|
41
|
+
|
|
42
|
+
## Dependencies
|
|
43
|
+
|
|
44
|
+
- TODO 98 (spans interact with column widths).
|
data/TODO.pdf/README.md
CHANGED
|
@@ -5,17 +5,14 @@ without InDesign Server. The pipeline has three layers:
|
|
|
5
5
|
|
|
6
6
|
1. **Text engine** (TODOs 01–05): a pure-Ruby text layout engine
|
|
7
7
|
that takes styled text runs + frame geometry and produces
|
|
8
|
-
positioned glyphs. Uses font metrics
|
|
9
|
-
|
|
8
|
+
positioned glyphs. Uses font metrics via pdfrb's typed Fonts API.
|
|
10
9
|
2. **PDF content stream** (TODOs 06–10): maps IDML page items
|
|
11
|
-
(shapes, text, colors, images) to
|
|
12
|
-
using pdfrb's Document/Canvas API.
|
|
13
|
-
|
|
10
|
+
(shapes, text, colors, images) to pdfrb's Canvas API.
|
|
14
11
|
3. **Pipeline** (TODOs 11–13): ties the text engine and PDF
|
|
15
12
|
output together. IDML Package → typed model → render plan →
|
|
16
13
|
pdfrb Document → PDF file.
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
All entries below are functionally DONE (basic CJK in TODO 13 is
|
|
16
|
+
the only PARTIAL — vertical writing mode, kinsoku, ruby, and
|
|
17
|
+
tate-chu-yoko remain stretch goals). Each file carries a
|
|
18
|
+
`## Status:` header with a brief implementation summary.
|
data/idml.gemspec
CHANGED
|
@@ -29,8 +29,9 @@ Gem::Specification.new do |spec|
|
|
|
29
29
|
spec.require_paths = ["lib"]
|
|
30
30
|
|
|
31
31
|
spec.add_dependency "bigdecimal"
|
|
32
|
+
spec.add_dependency "logger"
|
|
32
33
|
spec.add_dependency "lutaml-model", "~> 0.8.18"
|
|
33
|
-
spec.add_dependency "pdfrb"
|
|
34
|
+
spec.add_dependency "pdfrb", "~> 0.7"
|
|
34
35
|
spec.add_dependency "rubyzip"
|
|
35
36
|
spec.add_dependency "thor"
|
|
36
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
|
|
@@ -66,6 +66,15 @@ module Idml
|
|
|
66
66
|
map
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# True if at least one non-missing font declared in the
|
|
70
|
+
# document can be located on disk. Lets callers (and tests)
|
|
71
|
+
# gate font-embedding assertions on the document's fonts
|
|
72
|
+
# actually being installed, rather than silently falling
|
|
73
|
+
# back to the default.
|
|
74
|
+
def font_resolvable?
|
|
75
|
+
!resolve_document_font_path.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
69
78
|
def resource_for_family(writer, family)
|
|
70
79
|
path = find_font_file(family)
|
|
71
80
|
return DEFAULT_FONT unless path
|
|
@@ -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
|