idml 0.7.2 → 0.7.4
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/TODO.pdf/103-drop-caps.md +12 -1
- data/TODO.pdf/112-text-frame-vertical-justification.md +53 -0
- data/TODO.pdf/113-multi-column-text-frames.md +48 -0
- data/TODO.pdf/114-text-wrap-around-shapes.md +50 -0
- data/TODO.pdf/115-bullet-numbered-lists.md +49 -0
- data/lib/idml/render/drop_cap.rb +83 -0
- data/lib/idml/render/list_marker.rb +63 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +102 -1
- data/lib/idml/render/style_resolver.rb +28 -1
- data/lib/idml/render.rb +2 -0
- data/lib/idml/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f60e795f4325dcc4006a28915a18baf7069e21767694ca670c41f84db62d98d
|
|
4
|
+
data.tar.gz: 4f1f4846d41050b518c8add338ce431951b45ea58891b2c56154b47e59478bc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbf7caac497ce2ab01a8c35af7bad78a24b7a706db6baa582b7d416cae1ce4995f18a3903379c4e994ab49ca1f28efd33e162821dead95250ae8248c041d16cd
|
|
7
|
+
data.tar.gz: ed5d2ae0abf40c0c6bf101d3c05ca35f59bc48aefed14366de7f5ac0f15979ff458ca7cf8493d012c4f6a78b0c1889815f987ea3dc9f24971f31ae34583f6fc4
|
data/TODO.pdf/103-drop-caps.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# TODO PDF 103: Drop caps (DropCapLines, DropCapCharacters)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: PARTIAL — `Render::DropCap` helper computes drop-cap
|
|
4
|
+
geometry (font_size = base × lines, width via font_metrics.measure_text,
|
|
5
|
+
height = leading × lines). TextFrameRenderer.emit_drop_cap emits the
|
|
6
|
+
drop cap as an enlarged glyph at the paragraph's top-left when the
|
|
7
|
+
paragraph declares DropCapLines/DropCapCharacters > 0 and is the
|
|
8
|
+
chain's first paragraph.
|
|
9
|
+
|
|
10
|
+
Wrap-around text is NOT yet implemented — the drop cap renders as an
|
|
11
|
+
enlarged glyph; subsequent paragraph text flows normally and may
|
|
12
|
+
overlap the drop cap. Real wrap-around (indent lines 1..M to the
|
|
13
|
+
right of the drop cap) requires per-line wrap-width control in
|
|
14
|
+
VerticalLayout, which is a bigger refactor. Tracked as follow-up.
|
|
4
15
|
|
|
5
16
|
## Problem
|
|
6
17
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# TODO PDF 112: TextFrame vertical justification (TopAlign / CenterAlign / BottomAlign / JustifyAlign)
|
|
2
|
+
|
|
3
|
+
## Status: DONE for TopAlign / CenterAlign / BottomAlign. JustifyAlign
|
|
4
|
+
defers to TopAlign (inter-paragraph space distribution not yet
|
|
5
|
+
implemented). Content height is approximated by treating each run
|
|
6
|
+
as one line — wrap may produce more lines, so slack is over-estimated;
|
|
7
|
+
we'd rather under-offset than overflow.
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
`Elements::TextFramePreference` carries `VerticalJustification`
|
|
12
|
+
(attribute VerticalJustification on TextFramePreference_Object in
|
|
13
|
+
Styles.rnc). Possible values: TopAlign, CenterAlign, BottomAlign,
|
|
14
|
+
JustifyAlign.
|
|
15
|
+
|
|
16
|
+
Today TextFrameRenderer always starts text at the top of the frame
|
|
17
|
+
(`cursor_y = box[:y] + box[:height] - inset_top`). For
|
|
18
|
+
`VerticalJustification="CenterAlign"` documents, the entire text
|
|
19
|
+
block should be vertically centered within the frame's text area;
|
|
20
|
+
for `BottomAlign`, text sits at the bottom.
|
|
21
|
+
|
|
22
|
+
This is a common layout requirement — callouts, captions, footers,
|
|
23
|
+
and sidebars often use bottom or center vertical justification.
|
|
24
|
+
|
|
25
|
+
## What needs to happen
|
|
26
|
+
|
|
27
|
+
1. `TextFrameRenderer#engine_render` reads
|
|
28
|
+
`frame.text_frame_preference.vertical_justification`.
|
|
29
|
+
2. Compute the total content height (sum of all paragraph heights +
|
|
30
|
+
space_before/after).
|
|
31
|
+
3. If VerticalJustification is CenterAlign: top-offset =
|
|
32
|
+
(frame_text_area_height - content_height) / 2.
|
|
33
|
+
4. If BottomAlign: top-offset = (frame_text_area_height - content_height).
|
|
34
|
+
5. Adjust the initial `cursor_y` by top-offset before laying out
|
|
35
|
+
the first paragraph.
|
|
36
|
+
6. JustifyAlign: distribute extra vertical space between paragraphs
|
|
37
|
+
(more complex — defer).
|
|
38
|
+
|
|
39
|
+
## Acceptance criteria
|
|
40
|
+
|
|
41
|
+
- [ ] TextFrame with VerticalJustification="CenterAlign" centers
|
|
42
|
+
text vertically in the frame.
|
|
43
|
+
- [ ] TextFrame with VerticalJustification="BottomAlign" aligns
|
|
44
|
+
text to the bottom.
|
|
45
|
+
- [ ] TextFrame with VerticalJustification="TopAlign" (default)
|
|
46
|
+
renders as today.
|
|
47
|
+
- [ ] Spec coverage per value.
|
|
48
|
+
|
|
49
|
+
## Dependencies
|
|
50
|
+
|
|
51
|
+
- TODO 108 (multi-frame story flow) — content_height calculation
|
|
52
|
+
needs to consider all paragraphs that will render, which the
|
|
53
|
+
chain state already tracks.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# TODO PDF 113: Multi-column text frames (TextColumnCount, TextColumnGutter)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-10 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`Elements::TextFramePreference` declares:
|
|
8
|
+
- `TextColumnCount` — number of text columns in the frame (1-40)
|
|
9
|
+
- `TextColumnGutter` — gap between columns (in points)
|
|
10
|
+
- `TextColumnFixedWidth` — width of each column when uniform
|
|
11
|
+
- `UseFixedColumnWidth` — boolean
|
|
12
|
+
- `UseFlexibleColumnWidth` — boolean
|
|
13
|
+
- `TextColumnMaxWidth` — max column width when flexible
|
|
14
|
+
|
|
15
|
+
Real-world documents frequently use multi-column layouts
|
|
16
|
+
(magazines, newspapers, technical reports, brochures). Today the
|
|
17
|
+
renderer treats every text frame as a single column, ignoring
|
|
18
|
+
TextColumnCount > 1.
|
|
19
|
+
|
|
20
|
+
Effect: multi-column stories render as one wide column that over-
|
|
21
|
+
laps adjacent content. The PDF is unreadable for multi-column docs.
|
|
22
|
+
|
|
23
|
+
## What needs to happen
|
|
24
|
+
|
|
25
|
+
1. `TextFrameRenderer` reads `text_frame_preference.text_column_count`.
|
|
26
|
+
2. When > 1: split the frame's text area into N columns separated
|
|
27
|
+
by `text_column_gutter`.
|
|
28
|
+
3. Render text column-by-column: column 1 fills top-to-bottom,
|
|
29
|
+
overflow continues at top of column 2, etc.
|
|
30
|
+
4. StoryThreader chain state threads across columns WITHIN a frame
|
|
31
|
+
before crossing to the next frame.
|
|
32
|
+
5. Optional: support TextColumnFixedWidth vs flexible-width
|
|
33
|
+
distribution.
|
|
34
|
+
|
|
35
|
+
Architecture: this combines TODO 108 (chain flow) with column
|
|
36
|
+
awareness. Each column acts like a mini-frame in the chain.
|
|
37
|
+
|
|
38
|
+
## Acceptance criteria
|
|
39
|
+
|
|
40
|
+
- [ ] TextFrame with TextColumnCount=2 renders two side-by-side columns.
|
|
41
|
+
- [ ] Text overflows from column 1 → column 2 → next frame.
|
|
42
|
+
- [ ] TextColumnGutter honored as the gap between columns.
|
|
43
|
+
- [ ] Single-column frames render as today.
|
|
44
|
+
|
|
45
|
+
## Dependencies
|
|
46
|
+
|
|
47
|
+
- TODO 108 (multi-frame story flow) — column-to-column flow uses
|
|
48
|
+
the same chain state mechanism.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# TODO PDF 114: Text wrap around shapes (TextWrapPreference)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-10 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
IDML page items (Rectangle, Polygon, GraphicLine, Group, Image, EPS)
|
|
8
|
+
can declare a `<TextWrapPreference>` child that controls how text
|
|
9
|
+
in neighboring TextFrames wraps around the item. Settings include:
|
|
10
|
+
|
|
11
|
+
- WrapMode: None / Jump / Next / Bounding Box / Shape / Drop Cap
|
|
12
|
+
- Contour: the wrap boundary (could differ from the visual shape)
|
|
13
|
+
- Inverse: text wraps INSIDE the contour (rare)
|
|
14
|
+
- OffsetTop/Left/Bottom/Right: additional margin around the contour
|
|
15
|
+
|
|
16
|
+
Real documents wrap text around images and callout boxes constantly.
|
|
17
|
+
Without text wrap support, overlapping items produce unreadable
|
|
18
|
+
output — text from one frame runs over a graphic in another.
|
|
19
|
+
|
|
20
|
+
Today TextFrameRenderer knows nothing about TextWrapPreference on
|
|
21
|
+
other page items. It renders into the frame's full geometric
|
|
22
|
+
rectangle regardless of overlapping wrap contours.
|
|
23
|
+
|
|
24
|
+
## What needs to happen
|
|
25
|
+
|
|
26
|
+
1. `Render::TextWrapResolver` reads TextWrapPreference from all
|
|
27
|
+
page items in the spread and indexes their contours by Self.
|
|
28
|
+
2. Per TextFrame: compute the intersection of the frame's text area
|
|
29
|
+
with all wrap contours in the spread.
|
|
30
|
+
3. TextFrameRenderer queries the resolver per text line; each line's
|
|
31
|
+
wrap width is reduced by the overlap on the left and right.
|
|
32
|
+
4. Lines that fall entirely inside a wrap region are skipped.
|
|
33
|
+
|
|
34
|
+
This is a substantial feature — wrap computation is non-trivial
|
|
35
|
+
(geometry intersection, contour following). Most documents use a
|
|
36
|
+
simple Bounding Box mode (rectangle subtract), which is tractable.
|
|
37
|
+
|
|
38
|
+
## Acceptance criteria
|
|
39
|
+
|
|
40
|
+
- [ ] TextWrapMode="BoundingBox" with an image inside the frame's
|
|
41
|
+
bounds reduces wrap width on affected lines.
|
|
42
|
+
- [ ] TextWrapMode="None" (default) renders as today.
|
|
43
|
+
- [ ] OffsetTop/Left/Bottom/Right honored (expands the contour by
|
|
44
|
+
the offsets).
|
|
45
|
+
|
|
46
|
+
## Dependencies
|
|
47
|
+
|
|
48
|
+
- TODO 108 (multi-frame story flow) — wrap-aware wrap width needs
|
|
49
|
+
to combine with chain-threaded line layout.
|
|
50
|
+
- TODO 113 (multi-column) — wrap interacts with column boundaries.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# TODO PDF 115: Bullet and numbered list rendering
|
|
2
|
+
|
|
3
|
+
## Status: DONE for inline marker emission. Hanging-indent layout
|
|
4
|
+
(bullet in a left gutter with wrapped lines indented to align with
|
|
5
|
+
text after marker) is a follow-up — currently the marker sits inline
|
|
6
|
+
before the first run's text.
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
`Elements::ParagraphStyleRange` carries a large set of bullets and
|
|
11
|
+
numbering attributes (BulletsAndNumberingListType, NumberingExpression,
|
|
12
|
+
BulletsTextAfter, NumberingLevel, NumberingContinue, NumberingStartAt,
|
|
13
|
+
BulletsAlignment, NumberingAlignment, BulletCharacterType,
|
|
14
|
+
BulletCharacterValue, RestartPolicy, LowerLevel, UpperLevel).
|
|
15
|
+
|
|
16
|
+
The renderer ignores all of these. Lists appear in the PDF as plain
|
|
17
|
+
paragraphs without bullet markers or numbering.
|
|
18
|
+
|
|
19
|
+
Real documents (technical specs, tutorials, procedures, contracts)
|
|
20
|
+
use lists extensively.
|
|
21
|
+
|
|
22
|
+
## What needs to happen
|
|
23
|
+
|
|
24
|
+
1. Read BulletsAndNumberingListType from PSR:
|
|
25
|
+
- "BulletList" → emit a bullet glyph at the paragraph's start
|
|
26
|
+
- "NumberedList" → emit a number/letter at the paragraph's start
|
|
27
|
+
2. Apply BulletsAlignment (left/center/right) within a hanging-indent
|
|
28
|
+
gutter.
|
|
29
|
+
3. Auto-increment numbering per NumberingLevel +
|
|
30
|
+
NumberingContinue + NumberingStartAt rules.
|
|
31
|
+
4. Honor RestartPolicy for sub-list restart behavior.
|
|
32
|
+
5. BulletCharacterValue picks the glyph (Unicode codepoint).
|
|
33
|
+
|
|
34
|
+
Architecture: list markers prepend the paragraph's first run; the
|
|
35
|
+
renderer needs an in-memory counter per numbering chain (similar
|
|
36
|
+
to StoryChainController).
|
|
37
|
+
|
|
38
|
+
## Acceptance criteria
|
|
39
|
+
|
|
40
|
+
- [ ] PSR with BulletsAndNumberingListType="BulletList" renders a
|
|
41
|
+
bullet glyph before the paragraph's first character.
|
|
42
|
+
- [ ] PSR with BulletsAndNumberingListType="NumberedList" renders
|
|
43
|
+
a sequential number, auto-incremented per chain.
|
|
44
|
+
- [ ] BulletsTextAfter (e.g., ". ", ") ") honored as the marker's
|
|
45
|
+
suffix.
|
|
46
|
+
|
|
47
|
+
## Dependencies
|
|
48
|
+
|
|
49
|
+
- None — independent feature work on PSR.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Computes drop-cap geometry for a paragraph. IDML's DropCapLines
|
|
6
|
+
# (M) and DropCapCharacters (N) declare that the first N
|
|
7
|
+
# characters of the paragraph should render at M-lines-tall and
|
|
8
|
+
# the next M text lines should wrap to the right of the drop cap.
|
|
9
|
+
#
|
|
10
|
+
# The helper returns a `DropCapLayout` struct with the drop cap
|
|
11
|
+
# text, font size, width (for wrap offset), and total height.
|
|
12
|
+
# Returns nil when the paragraph doesn't declare drop caps.
|
|
13
|
+
module DropCap
|
|
14
|
+
DropCapLayout = Struct.new(
|
|
15
|
+
:text, :width, :height, :font_size, :wrap_offset, :lines,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Returns a DropCapLayout for the paragraph, or nil when drop
|
|
20
|
+
# caps aren't declared. `font_metrics` measures the drop-cap
|
|
21
|
+
# text's natural width. `base_size` is the paragraph's normal
|
|
22
|
+
# font size (drop cap is enlarged relative to it).
|
|
23
|
+
def self.layout(paragraph, font_metrics:, base_size:, leading:)
|
|
24
|
+
return nil unless active?(paragraph)
|
|
25
|
+
return nil unless font_metrics
|
|
26
|
+
|
|
27
|
+
chars = paragraph.drop_cap_characters.to_i
|
|
28
|
+
lines = paragraph.drop_cap_lines.to_i
|
|
29
|
+
return nil if chars <= 0 || lines <= 0
|
|
30
|
+
|
|
31
|
+
build_layout(paragraph, font_metrics, base_size, leading, chars, lines)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when the paragraph declares an active drop cap
|
|
35
|
+
# (both DropCapLines and DropCapCharacters are positive integers).
|
|
36
|
+
def self.active?(paragraph)
|
|
37
|
+
return false unless paragraph
|
|
38
|
+
return false unless paragraph.drop_cap_lines.to_i.positive?
|
|
39
|
+
|
|
40
|
+
paragraph.drop_cap_characters.to_i.positive?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Pulls the first N characters from the paragraph's first run.
|
|
44
|
+
# If the first run is shorter than N, takes from subsequent runs
|
|
45
|
+
# until N chars are accumulated (rare — drop caps typically use
|
|
46
|
+
# 1-2 chars).
|
|
47
|
+
def self.extract_drop_cap_text(paragraph, n)
|
|
48
|
+
runs = paragraph.runs
|
|
49
|
+
return nil if runs.empty?
|
|
50
|
+
|
|
51
|
+
collected = +""
|
|
52
|
+
runs.each do |run|
|
|
53
|
+
needed = n - collected.length
|
|
54
|
+
break if needed <= 0
|
|
55
|
+
|
|
56
|
+
collected << run.text[0, needed]
|
|
57
|
+
break if collected.length >= n
|
|
58
|
+
end
|
|
59
|
+
collected
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.build_layout(paragraph, font_metrics, base_size, leading,
|
|
63
|
+
chars, lines)
|
|
64
|
+
text = extract_drop_cap_text(paragraph, chars)
|
|
65
|
+
return nil if text.nil? || text.empty?
|
|
66
|
+
|
|
67
|
+
font_size = base_size * lines
|
|
68
|
+
width = font_metrics.measure_text(text, font_size).to_f
|
|
69
|
+
return nil unless width&.positive?
|
|
70
|
+
|
|
71
|
+
DropCapLayout.new(
|
|
72
|
+
text: text,
|
|
73
|
+
width: width,
|
|
74
|
+
height: leading * lines,
|
|
75
|
+
font_size: font_size,
|
|
76
|
+
wrap_offset: width,
|
|
77
|
+
lines: lines,
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :build_layout
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Produces a list-marker string for a paragraph (bullet glyph or
|
|
6
|
+
# numbered-list expression). IDML stores the resolved marker
|
|
7
|
+
# directly on the PSR (BulletCharacterValue as a Unicode codepoint,
|
|
8
|
+
# NumberingExpression as the rendered text), so the renderer
|
|
9
|
+
# doesn't need to maintain its own counter.
|
|
10
|
+
#
|
|
11
|
+
# Used by `StyleResolver` to prepend the marker to the paragraph's
|
|
12
|
+
# first run's text. The marker becomes part of the run's natural
|
|
13
|
+
# text flow — no special positioning needed for MVP.
|
|
14
|
+
#
|
|
15
|
+
# Hanging-indent layout (bullet aligned in a left gutter with
|
|
16
|
+
# wrapped lines indented to align with text after the marker) is
|
|
17
|
+
# a follow-up; for now the marker sits inline before the text.
|
|
18
|
+
module ListMarker
|
|
19
|
+
DEFAULT_BULLET = "•".freeze # BULLET
|
|
20
|
+
DEFAULT_TEXT_AFTER = "\t".freeze
|
|
21
|
+
|
|
22
|
+
# Returns the marker string for the paragraph, or nil when the
|
|
23
|
+
# paragraph isn't part of a list.
|
|
24
|
+
def self.marker_for(paragraph)
|
|
25
|
+
return nil unless paragraph
|
|
26
|
+
|
|
27
|
+
case paragraph.bullets_and_numbering_list_type
|
|
28
|
+
when "BulletList" then bullet_marker(paragraph)
|
|
29
|
+
when "NumberedList" then numbered_marker(paragraph)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.bullet_marker(paragraph)
|
|
34
|
+
bullet = bullet_char(paragraph)
|
|
35
|
+
suffix = text_after(paragraph)
|
|
36
|
+
"#{bullet}#{suffix}"
|
|
37
|
+
end
|
|
38
|
+
private_class_method :bullet_marker
|
|
39
|
+
|
|
40
|
+
def self.numbered_marker(paragraph)
|
|
41
|
+
expr = paragraph.numbering_expression
|
|
42
|
+
return nil if expr.nil? || expr.empty?
|
|
43
|
+
|
|
44
|
+
suffix = text_after(paragraph)
|
|
45
|
+
"#{expr}#{suffix}"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :numbered_marker
|
|
48
|
+
|
|
49
|
+
def self.bullet_char(paragraph)
|
|
50
|
+
codepoint = paragraph.bullet_character_value
|
|
51
|
+
return DEFAULT_BULLET unless codepoint&.positive?
|
|
52
|
+
|
|
53
|
+
[codepoint].pack("U")
|
|
54
|
+
end
|
|
55
|
+
private_class_method :bullet_char
|
|
56
|
+
|
|
57
|
+
def self.text_after(paragraph)
|
|
58
|
+
paragraph.bullets_text_after || DEFAULT_TEXT_AFTER
|
|
59
|
+
end
|
|
60
|
+
private_class_method :text_after
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -157,8 +157,9 @@ module Idml
|
|
|
157
157
|
# next frame in the chain.
|
|
158
158
|
def self.engine_render(canvas, state, context, box, font)
|
|
159
159
|
layout_frame = layout_frame(context.item, box)
|
|
160
|
-
|
|
160
|
+
top_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
|
|
161
161
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
162
|
+
cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
|
|
162
163
|
char_cursor = state.char_cursor
|
|
163
164
|
|
|
164
165
|
if state.current_paragraph
|
|
@@ -174,6 +175,77 @@ module Idml
|
|
|
174
175
|
end
|
|
175
176
|
private_class_method :engine_render
|
|
176
177
|
|
|
178
|
+
# Computes the starting cursor_y for vertical justification.
|
|
179
|
+
# IDML's TextFramePreference.VerticalJustification can be:
|
|
180
|
+
# - TopAlign (default): start at frame top, no offset
|
|
181
|
+
# - CenterAlign: center the content block vertically
|
|
182
|
+
# - BottomAlign: align content to the frame bottom
|
|
183
|
+
# - JustifyAlign: distribute extra space between paragraphs
|
|
184
|
+
# (deferred — falls back to TopAlign behavior)
|
|
185
|
+
#
|
|
186
|
+
# Content height is estimated by walking paragraphs and
|
|
187
|
+
# summing leading × lines + space_before/after. Without
|
|
188
|
+
# shaping/wrapping we can't know exact line count, so we
|
|
189
|
+
# approximate by treating each run as one line.
|
|
190
|
+
def self.vertical_justify_top(top_y, bottom_limit, state, context)
|
|
191
|
+
justification = text_frame_vertical_justification(context)
|
|
192
|
+
return top_y unless %w[CenterAlign BottomAlign].include?(justification)
|
|
193
|
+
|
|
194
|
+
content_height = estimate_content_height(state)
|
|
195
|
+
available = top_y - bottom_limit
|
|
196
|
+
slack = [available - content_height, 0].max
|
|
197
|
+
return top_y if slack.zero?
|
|
198
|
+
|
|
199
|
+
case justification
|
|
200
|
+
when "CenterAlign" then top_y - (slack / 2)
|
|
201
|
+
when "BottomAlign" then top_y - slack
|
|
202
|
+
else top_y
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
private_class_method :vertical_justify_top
|
|
206
|
+
|
|
207
|
+
def self.text_frame_vertical_justification(context)
|
|
208
|
+
pref = context.item&.text_frame_preference&.first
|
|
209
|
+
pref&.vertical_justification
|
|
210
|
+
end
|
|
211
|
+
private_class_method :text_frame_vertical_justification
|
|
212
|
+
|
|
213
|
+
# Estimates total renderable content height for vertical
|
|
214
|
+
# justification. Walks paragraphs and runs, summing leading
|
|
215
|
+
# per run plus paragraph space_before/after. Approximation:
|
|
216
|
+
# treats each run as one line (wrap may produce more lines,
|
|
217
|
+
# which would over-estimate slack; acceptable for
|
|
218
|
+
# justification since we'd rather under-offset than overflow).
|
|
219
|
+
def self.estimate_content_height(state)
|
|
220
|
+
paragraphs = paragraphs_for_estimate(state)
|
|
221
|
+
return 0 if paragraphs.empty?
|
|
222
|
+
|
|
223
|
+
paragraphs.sum do |paragraph|
|
|
224
|
+
paragraph_height(paragraph)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
private_class_method :estimate_content_height
|
|
228
|
+
|
|
229
|
+
def self.paragraphs_for_estimate(state)
|
|
230
|
+
result = []
|
|
231
|
+
result << state.current_paragraph if state.current_paragraph
|
|
232
|
+
result + state.paragraphs
|
|
233
|
+
end
|
|
234
|
+
private_class_method :paragraphs_for_estimate
|
|
235
|
+
|
|
236
|
+
def self.paragraph_height(paragraph)
|
|
237
|
+
size = paragraph.runs.first&.point_size || DEFAULT_SIZE
|
|
238
|
+
leading = leading_for(paragraph, size)
|
|
239
|
+
line_count = [paragraph.runs.length, 1].max
|
|
240
|
+
(leading * line_count) + space_before_after(paragraph)
|
|
241
|
+
end
|
|
242
|
+
private_class_method :paragraph_height
|
|
243
|
+
|
|
244
|
+
def self.space_before_after(paragraph)
|
|
245
|
+
(paragraph.space_before || 0) + (paragraph.space_after || 0)
|
|
246
|
+
end
|
|
247
|
+
private_class_method :space_before_after
|
|
248
|
+
|
|
177
249
|
def self.resume_paragraph(canvas, state, context, layout_frame, font,
|
|
178
250
|
cursor_y, bottom_limit, char_cursor)
|
|
179
251
|
paragraph = state.current_paragraph
|
|
@@ -233,6 +305,8 @@ module Idml
|
|
|
233
305
|
|
|
234
306
|
emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
235
307
|
frame_left, frame_right, first_paragraph)
|
|
308
|
+
emit_drop_cap(canvas, paragraph, context, frame_left, cursor_y,
|
|
309
|
+
first_paragraph)
|
|
236
310
|
|
|
237
311
|
rendered_count = 0
|
|
238
312
|
runs.each do |run|
|
|
@@ -260,6 +334,33 @@ module Idml
|
|
|
260
334
|
end
|
|
261
335
|
private_class_method :render_runs_for_paragraph
|
|
262
336
|
|
|
337
|
+
# Renders the drop cap for a paragraph (when declared and the
|
|
338
|
+
# first paragraph of the chain). Wrap-around text is not yet
|
|
339
|
+
# implemented — the drop cap renders as an enlarged glyph at
|
|
340
|
+
# the paragraph's top-left; subsequent text may overlap it.
|
|
341
|
+
# Marked as a follow-up in TODO 103.
|
|
342
|
+
def self.emit_drop_cap(canvas, paragraph, context, frame_left,
|
|
343
|
+
cursor_y, first_paragraph)
|
|
344
|
+
return nil unless first_paragraph
|
|
345
|
+
return nil unless DropCap.active?(paragraph)
|
|
346
|
+
|
|
347
|
+
base_size = paragraph.runs.first&.point_size || DEFAULT_SIZE
|
|
348
|
+
leading = leading_for(paragraph, base_size)
|
|
349
|
+
drop_cap = DropCap.layout(paragraph,
|
|
350
|
+
font_metrics: context.font_metrics,
|
|
351
|
+
base_size: base_size, leading: leading)
|
|
352
|
+
return nil unless drop_cap
|
|
353
|
+
|
|
354
|
+
drop_at_y = cursor_y - drop_cap.font_size + (base_size * 0.25)
|
|
355
|
+
canvas.text(
|
|
356
|
+
drop_cap.text,
|
|
357
|
+
at: [frame_left, drop_at_y],
|
|
358
|
+
font: font_for_run(paragraph.runs.first, context),
|
|
359
|
+
size: drop_cap.font_size,
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
private_class_method :emit_drop_cap
|
|
363
|
+
|
|
263
364
|
def self.emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
264
365
|
frame_left, frame_right,
|
|
265
366
|
first_paragraph)
|
|
@@ -54,6 +54,12 @@ module Idml
|
|
|
54
54
|
:left_indent,
|
|
55
55
|
:right_indent,
|
|
56
56
|
:auto_leading,
|
|
57
|
+
:drop_cap_lines,
|
|
58
|
+
:bullets_and_numbering_list_type,
|
|
59
|
+
:bullet_character_value,
|
|
60
|
+
:bullets_text_after,
|
|
61
|
+
:numbering_expression,
|
|
62
|
+
:drop_cap_characters,
|
|
57
63
|
# RuleAbove / RuleBelow — paragraph rules (horizontal lines
|
|
58
64
|
# above/below the paragraph block). Driven by PSR attributes.
|
|
59
65
|
:rule_above,
|
|
@@ -113,7 +119,7 @@ module Idml
|
|
|
113
119
|
runs = csr_runs(psr, condition_filter: condition_filter)
|
|
114
120
|
next if runs.empty?
|
|
115
121
|
|
|
116
|
-
Paragraph.new(
|
|
122
|
+
paragraph = Paragraph.new(
|
|
117
123
|
runs: runs,
|
|
118
124
|
alignment: runs.first.alignment,
|
|
119
125
|
space_before: psr.space_before,
|
|
@@ -122,6 +128,12 @@ module Idml
|
|
|
122
128
|
left_indent: psr.left_indent,
|
|
123
129
|
right_indent: psr.right_indent,
|
|
124
130
|
auto_leading: psr.auto_leading,
|
|
131
|
+
drop_cap_lines: psr.drop_cap_lines,
|
|
132
|
+
drop_cap_characters: psr.drop_cap_characters,
|
|
133
|
+
bullets_and_numbering_list_type: psr.bullets_and_numbering_list_type,
|
|
134
|
+
bullet_character_value: psr.bullet_character_value,
|
|
135
|
+
bullets_text_after: psr.bullets_text_after,
|
|
136
|
+
numbering_expression: psr.numbering_expression,
|
|
125
137
|
rule_above: psr.rule_above,
|
|
126
138
|
rule_above_line_weight: psr.rule_above_line_weight,
|
|
127
139
|
rule_above_color: psr.stroke_color,
|
|
@@ -139,6 +151,8 @@ module Idml
|
|
|
139
151
|
rule_below_right_indent: psr.rule_below_right_indent,
|
|
140
152
|
rule_below_width: psr.rule_below_width,
|
|
141
153
|
)
|
|
154
|
+
prepend_list_marker(paragraph)
|
|
155
|
+
paragraph
|
|
142
156
|
end
|
|
143
157
|
end
|
|
144
158
|
|
|
@@ -189,6 +203,19 @@ module Idml
|
|
|
189
203
|
end
|
|
190
204
|
private_class_method :alignment_for
|
|
191
205
|
|
|
206
|
+
# Prepends the list marker (bullet glyph or numbered expression)
|
|
207
|
+
# to the paragraph's first run when the paragraph is part of
|
|
208
|
+
# a list. Mutates the first run's text in place.
|
|
209
|
+
def self.prepend_list_marker(paragraph)
|
|
210
|
+
marker = ListMarker.marker_for(paragraph)
|
|
211
|
+
return unless marker
|
|
212
|
+
return if paragraph.runs.empty?
|
|
213
|
+
|
|
214
|
+
first_run = paragraph.runs.first
|
|
215
|
+
first_run.text = "#{marker}#{first_run.text}"
|
|
216
|
+
end
|
|
217
|
+
private_class_method :prepend_list_marker
|
|
218
|
+
|
|
192
219
|
# Concatenate runs into a single block. Used when the renderer
|
|
193
220
|
# can't handle per-run styling (e.g., no font metrics available).
|
|
194
221
|
def self.concatenate(runs)
|
data/lib/idml/render.rb
CHANGED
|
@@ -15,6 +15,8 @@ module Idml
|
|
|
15
15
|
autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
|
|
16
16
|
autoload :CharacterStyle, "#{__dir__}/render/character_style"
|
|
17
17
|
autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
|
|
18
|
+
autoload :DropCap, "#{__dir__}/render/drop_cap"
|
|
19
|
+
autoload :ListMarker, "#{__dir__}/render/list_marker"
|
|
18
20
|
autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
|
|
19
21
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
20
22
|
autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
|
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.7.
|
|
4
|
+
version: 0.7.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -168,6 +168,10 @@ files:
|
|
|
168
168
|
- TODO.pdf/11-font-embedding.md
|
|
169
169
|
- TODO.pdf/110-conditional-text.md
|
|
170
170
|
- TODO.pdf/111-footnote-support.md
|
|
171
|
+
- TODO.pdf/112-text-frame-vertical-justification.md
|
|
172
|
+
- TODO.pdf/113-multi-column-text-frames.md
|
|
173
|
+
- TODO.pdf/114-text-wrap-around-shapes.md
|
|
174
|
+
- TODO.pdf/115-bullet-numbered-lists.md
|
|
171
175
|
- TODO.pdf/12-idml-to-pdf-pipeline.md
|
|
172
176
|
- TODO.pdf/13-cjk-text-layout.md
|
|
173
177
|
- TODO.pdf/14-page-item-element-models.md
|
|
@@ -430,6 +434,7 @@ files:
|
|
|
430
434
|
- lib/idml/render/color_helper.rb
|
|
431
435
|
- lib/idml/render/color_resolver.rb
|
|
432
436
|
- lib/idml/render/condition_filter.rb
|
|
437
|
+
- lib/idml/render/drop_cap.rb
|
|
433
438
|
- lib/idml/render/font_reference_resolver.rb
|
|
434
439
|
- lib/idml/render/font_setup.rb
|
|
435
440
|
- lib/idml/render/gradient_resolver.rb
|
|
@@ -439,6 +444,7 @@ files:
|
|
|
439
444
|
- lib/idml/render/image.rb
|
|
440
445
|
- lib/idml/render/image_collector.rb
|
|
441
446
|
- lib/idml/render/layer_filter.rb
|
|
447
|
+
- lib/idml/render/list_marker.rb
|
|
442
448
|
- lib/idml/render/metadata_builder.rb
|
|
443
449
|
- lib/idml/render/page_item_renderer.rb
|
|
444
450
|
- lib/idml/render/paragraph_rules.rb
|