idml 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/TODO.pdf/102-anchored-objects.md +40 -36
- data/TODO.pdf/111-footnote-support.md +45 -35
- data/TODO.pdf/114-text-wrap-around-shapes.md +15 -7
- data/lib/idml/elements/character_style_range.rb +18 -0
- data/lib/idml/elements/footnote.rb +32 -0
- data/lib/idml/elements/graphic_line.rb +5 -0
- data/lib/idml/elements/group.rb +3 -0
- data/lib/idml/elements/oval.rb +5 -0
- data/lib/idml/elements/path.rb +5 -0
- data/lib/idml/elements/polygon.rb +5 -0
- data/lib/idml/elements/rectangle.rb +3 -0
- data/lib/idml/elements/story_inner.rb +2 -0
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/render/footnote.rb +211 -0
- data/lib/idml/render/render_context.rb +1 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +140 -28
- data/lib/idml/render/spread_renderer.rb +5 -0
- data/lib/idml/render/style_resolver.rb +93 -38
- data/lib/idml/render/text_wrap_resolver.rb +22 -3
- data/lib/idml/render.rb +1 -0
- data/lib/idml/text_engine/vertical_layout.rb +11 -1
- data/lib/idml/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad2709aae7d11f8a91843141700ab7882b9b7a196480d7b6077fe4f0b6473d16
|
|
4
|
+
data.tar.gz: b6fd8d8df9996dabd3fd4ffbfc9fb788930cb3ca04bb6f014ae8b3c9fce5b6af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20154b884fb1688985cdca3c7a3866e7c55be9059768d4a00eae4058bdd237f3606c964318d224bdd198853bc61a0a9167012b5dc6cc558f4a31537146a45a27
|
|
7
|
+
data.tar.gz: 7075c7451518b208e6ce17da854fb9954048ed40825287dc87bf4b4543467a0677a277fdf49f124703c353dfc4faf1b770de936d69acf1b0958998887a1cb101
|
|
@@ -1,41 +1,45 @@
|
|
|
1
1
|
# TODO PDF 102: Anchored objects (anchored object settings)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
`
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-16
|
|
4
|
+
|
|
5
|
+
## What was built
|
|
6
|
+
|
|
7
|
+
- Model wiring: `anchored_object_setting` attribute + mapping on
|
|
8
|
+
Rectangle, Oval, Polygon, GraphicLine, Group, and Path (per
|
|
9
|
+
`*_Object` in Story.rnc — every page item can carry
|
|
10
|
+
`<AnchoredObjectSetting>`). The setting parses AnchoredPosition
|
|
11
|
+
(InlinePosition / AboveLine / Anchored), AnchorPoint,
|
|
12
|
+
AnchorXoffset / AnchorYoffset, Horizontal/VerticalReferencePoint,
|
|
13
|
+
and the remaining schema attributes.
|
|
14
|
+
- `CharacterStyleRange` now models its story-embedded page-item
|
|
15
|
+
children: `rectangle`, `oval`, `polygon`, `graphic_line`,
|
|
16
|
+
`group`, `text_frame` collections. Previously these elements were
|
|
17
|
+
dropped at parse time — anchored objects were lost entirely.
|
|
18
|
+
- Rendering: `TextFrameRenderer` discovers embedded items via the
|
|
19
|
+
shared `story_csrs` walk and renders each through
|
|
20
|
+
`PageItemRenderer` dispatch (fill, stroke, gradients, images —
|
|
21
|
+
everything the spread-level path does) with a child context.
|
|
22
|
+
Items render at their own stored geometry: InDesign resolves an
|
|
23
|
+
anchored object's position when saving the file, so the stored
|
|
24
|
+
ItemTransform + PathGeometry IS the anchored position for all
|
|
25
|
+
three AnchorTypes.
|
|
26
|
+
|
|
27
|
+
## Known limitations
|
|
28
|
+
|
|
29
|
+
- AnchorType-specific text reflow is not simulated: the layout
|
|
30
|
+
engine positions body text independently of embedded items, so
|
|
31
|
+
body text may overlap inline/above-line objects in frames whose
|
|
32
|
+
stored text positions differ from our layout engine's output.
|
|
33
|
+
- HorizontalReferencePoint / VerticalReferencePoint /
|
|
34
|
+
HorizontalAlignment / VerticalAlignment on the setting are parsed
|
|
35
|
+
but not consulted (stored geometry already encodes the result).
|
|
29
36
|
|
|
30
37
|
## Acceptance criteria
|
|
31
38
|
|
|
32
|
-
- [
|
|
33
|
-
at
|
|
34
|
-
- [
|
|
35
|
-
|
|
36
|
-
- [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## Dependencies
|
|
40
|
-
|
|
41
|
-
- TODO 94 (need to know character positions in the text flow).
|
|
39
|
+
- [x] Page item with AnchoredObjectSetting AnchorType="Inline"
|
|
40
|
+
renders at its anchored position (stored geometry).
|
|
41
|
+
- [x] Page item with AnchorType="Above Line" renders above the
|
|
42
|
+
anchored line (stored geometry).
|
|
43
|
+
- [x] Page item with AnchorType="Anchored" renders at the position
|
|
44
|
+
derived from the anchor (stored geometry; AnchorX/Yoffset
|
|
45
|
+
parsing verified).
|
|
@@ -1,40 +1,50 @@
|
|
|
1
1
|
# TODO PDF 111: Footnote support
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-16
|
|
4
|
+
|
|
5
|
+
## What was built
|
|
6
|
+
|
|
7
|
+
- `Elements::Footnote` — schema-faithful model of `Footnote_Object`
|
|
8
|
+
(Story.rnc): no attributes, PSR/CSR children carrying the footnote
|
|
9
|
+
text. Wired into `CharacterStyleRange` and `StoryInner` (`footnote`
|
|
10
|
+
collection). Footnote text is excluded from `text_content` so it
|
|
11
|
+
never leaks into body text.
|
|
12
|
+
- `Render::Footnote` — footnote semantics unit (MECE):
|
|
13
|
+
- `Counter`: story-scoped sequential numbering, honoring
|
|
14
|
+
FootnoteOption `StartAt`.
|
|
15
|
+
- `marker_run`: superscript marker emitted into the body text at the
|
|
16
|
+
anchoring CSR's position; inherits the CSR's character styling.
|
|
17
|
+
- `extract`: footnote paragraphs via StyleResolver with the marker
|
|
18
|
+
text (`Prefix + number + Suffix`) prefixed to the first run.
|
|
19
|
+
- `layout_entries` / `reserved_height`: one shared Shaper →
|
|
20
|
+
LineBreaker → VerticalLayout walk drives both height measurement
|
|
21
|
+
and rendering, so the reserved area always matches what's drawn.
|
|
22
|
+
- `emit_separator`: separator rule honoring FootnoteOption RuleOn /
|
|
23
|
+
RuleLineWeight / RuleWidth / RuleLeftIndent.
|
|
24
|
+
- `TextFrameRenderer`: marker runs are collected during body layout
|
|
25
|
+
(`register_footnote`), each raising the effective bottom limit so
|
|
26
|
+
body text avoids the footnote area (incremental reservation,
|
|
27
|
+
InDesign-style); after body layout, `render_footnote_entries` draws
|
|
28
|
+
the separator and the footnote paragraphs above the frame's content
|
|
29
|
+
bottom. Per-column in multi-column frames.
|
|
30
|
+
- `FootnoteOption` (Preferences.xml) honored: StartAt, Prefix, Suffix,
|
|
31
|
+
RuleOn, RuleLineWeight, RuleWidth, RuleLeftIndent, Spacer
|
|
32
|
+
(separator gap), SpaceBetween (inter-entry gap).
|
|
33
|
+
|
|
34
|
+
## Known limitations
|
|
35
|
+
|
|
36
|
+
- Numbering restarts per story; document-wide continuous numbering
|
|
37
|
+
and per-section restart are not modeled.
|
|
38
|
+
- Footnote overflow balancing (a footnote too tall for its frame
|
|
39
|
+
moving wholly to the next frame, NoSplitting) is not modeled.
|
|
40
|
+
- The simple-render fallback (no font metrics) shows markers inline
|
|
41
|
+
but does not render footnote text at the frame bottom.
|
|
42
|
+
- Footnotes nested in CSRs inside CSRs (CSR > CSR > Footnote) are
|
|
43
|
+
not extracted; only direct CSR children emit markers.
|
|
30
44
|
|
|
31
45
|
## Acceptance criteria
|
|
32
46
|
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
36
|
-
|
|
37
|
-
## Dependencies
|
|
38
|
-
|
|
39
|
-
- TODO 108 (multi-frame story flow) — footnotes need to know which
|
|
40
|
-
page a reference appears on.
|
|
47
|
+
- [x] Footnote text rendered at bottom of containing frame.
|
|
48
|
+
- [x] Footnote marker in body text (superscript, numbered).
|
|
49
|
+
- [x] Numbering honors FootnoteOption StartAt / Prefix / Suffix
|
|
50
|
+
(per-story scope).
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# TODO PDF 114: Text wrap around shapes (TextWrapPreference)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
contours from page items declaring TextWrapPreference
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
## Status: DONE for BoundingBox mode — `Render::TextWrapResolver` computes
|
|
4
|
+
wrap contours from page items declaring TextWrapPreference with
|
|
5
|
+
TextWrapMode != "None". SpreadRenderer builds the resolver and passes
|
|
6
|
+
it via RenderContext. TextFrameRenderer's `text_wrap_overlap` queries
|
|
7
|
+
overlap at each run's y position and reduces the wrap width — text
|
|
8
|
+
flows around the shape instead of running over it.
|
|
9
|
+
|
|
10
|
+
Shape page items (Rectangle, Oval, Polygon, GraphicLine, Path) all
|
|
11
|
+
carry the TextWrapPreference child element. Non-shape items (Page,
|
|
12
|
+
Link) are filtered by WRAPPABLE_TYPES to avoid NoMethodError.
|
|
13
|
+
|
|
14
|
+
Per-run approximation: all lines in a run get the same reduced width.
|
|
15
|
+
True per-line adjustment (when a run spans the contour boundary)
|
|
16
|
+
requires line-count-aware layout. Shape mode (contour following) and
|
|
17
|
+
Inverse mode remain unsupported.
|
|
10
18
|
|
|
11
19
|
## Problem
|
|
12
20
|
|
|
@@ -300,6 +300,17 @@ module Idml
|
|
|
300
300
|
collection: true
|
|
301
301
|
attribute :table, Idml::Elements::Table, collection: true
|
|
302
302
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
303
|
+
attribute :footnote, Idml::Elements::Footnote, collection: true
|
|
304
|
+
# Anchored page items embedded in the story flow. Their
|
|
305
|
+
# AnchoredObjectSetting (child of each item) declares how the
|
|
306
|
+
# item anchors to the surrounding text.
|
|
307
|
+
attribute :rectangle, Idml::Elements::Rectangle, collection: true
|
|
308
|
+
attribute :oval, Idml::Elements::Oval, collection: true
|
|
309
|
+
attribute :polygon, Idml::Elements::Polygon, collection: true
|
|
310
|
+
attribute :graphic_line, Idml::Elements::GraphicLine,
|
|
311
|
+
collection: true
|
|
312
|
+
attribute :group, Idml::Elements::Group, collection: true
|
|
313
|
+
attribute :text_frame, Idml::Elements::TextFrame, collection: true
|
|
303
314
|
|
|
304
315
|
xml do
|
|
305
316
|
root "CharacterStyleRange"
|
|
@@ -655,6 +666,13 @@ module Idml
|
|
|
655
666
|
map_element "HyperlinkTextSource", to: :hyperlink_text_source
|
|
656
667
|
map_element "Table", to: :table
|
|
657
668
|
map_element "XMLElement", to: :xml_element
|
|
669
|
+
map_element "Footnote", to: :footnote
|
|
670
|
+
map_element "Rectangle", to: :rectangle
|
|
671
|
+
map_element "Oval", to: :oval
|
|
672
|
+
map_element "Polygon", to: :polygon
|
|
673
|
+
map_element "GraphicLine", to: :graphic_line
|
|
674
|
+
map_element "Group", to: :group
|
|
675
|
+
map_element "TextFrame", to: :text_frame
|
|
658
676
|
end
|
|
659
677
|
|
|
660
678
|
def text_content
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Elements
|
|
5
|
+
# `<Footnote>` — story-embedded footnote content. Schema-faithful
|
|
6
|
+
# model of `Footnote_Object` in
|
|
7
|
+
# `reference-docs/schemas/package/Stories/Story.rnc`: the element
|
|
8
|
+
# declares no attributes; its children are HiddenText /
|
|
9
|
+
# GaijiOwnedItemObject / Table / TextVariableInstance /
|
|
10
|
+
# ParagraphStyleRange / CharacterStyleRange. Of these, the
|
|
11
|
+
# ParagraphStyleRange (with its CSR children) and
|
|
12
|
+
# CharacterStyleRange children carry the footnote text; the rest
|
|
13
|
+
# are parsed by their own models where relevant.
|
|
14
|
+
#
|
|
15
|
+
# A Footnote sits inside the CharacterStyleRange that owns the
|
|
16
|
+
# anchor position: the body-text marker renders where the CSR's
|
|
17
|
+
# text ends, and the Footnote's paragraphs render at the bottom
|
|
18
|
+
# of the frame holding that marker (see Render::Footnote).
|
|
19
|
+
class Footnote < Lutaml::Model::Serializable
|
|
20
|
+
attribute :paragraph_style_range, Idml::Elements::ParagraphStyleRange,
|
|
21
|
+
collection: true
|
|
22
|
+
attribute :character_style_range, Idml::Elements::CharacterStyleRange,
|
|
23
|
+
collection: true
|
|
24
|
+
|
|
25
|
+
xml do
|
|
26
|
+
root "Footnote"
|
|
27
|
+
map_element "ParagraphStyleRange", to: :paragraph_style_range
|
|
28
|
+
map_element "CharacterStyleRange", to: :character_style_range
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -18,6 +18,9 @@ module Idml
|
|
|
18
18
|
attribute :item_layer, :string
|
|
19
19
|
attribute :properties, Idml::Elements::Properties, collection: true
|
|
20
20
|
attribute :transparency_setting, Idml::Elements::TransparencySetting
|
|
21
|
+
attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
|
|
22
|
+
attribute :anchored_object_setting,
|
|
23
|
+
Idml::Elements::AnchoredObjectSetting
|
|
21
24
|
|
|
22
25
|
xml do
|
|
23
26
|
root "GraphicLine"
|
|
@@ -34,6 +37,8 @@ module Idml
|
|
|
34
37
|
map_attribute "ItemLayer", to: :item_layer
|
|
35
38
|
map_element "Properties", to: :properties
|
|
36
39
|
map_element "TransparencySetting", to: :transparency_setting
|
|
40
|
+
map_element "TextWrapPreference", to: :text_wrap_preference
|
|
41
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def geometric_bounds
|
data/lib/idml/elements/group.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Idml
|
|
|
10
10
|
attribute :item_transform, :string
|
|
11
11
|
attribute :visible, :boolean
|
|
12
12
|
attribute :item_layer, :string
|
|
13
|
+
attribute :anchored_object_setting,
|
|
14
|
+
Idml::Elements::AnchoredObjectSetting
|
|
13
15
|
attribute :rectangle, Idml::Elements::Rectangle, collection: true
|
|
14
16
|
attribute :text_frame, Idml::Elements::TextFrame, collection: true
|
|
15
17
|
attribute :polygon, Idml::Elements::Polygon, collection: true
|
|
@@ -22,6 +24,7 @@ module Idml
|
|
|
22
24
|
map_attribute "ItemTransform", to: :item_transform
|
|
23
25
|
map_attribute "Visible", to: :visible
|
|
24
26
|
map_attribute "ItemLayer", to: :item_layer
|
|
27
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
25
28
|
map_element "Rectangle", to: :rectangle
|
|
26
29
|
map_element "TextFrame", to: :text_frame
|
|
27
30
|
map_element "Polygon", to: :polygon
|
data/lib/idml/elements/oval.rb
CHANGED
|
@@ -29,6 +29,9 @@ module Idml
|
|
|
29
29
|
attribute :item_layer, :string
|
|
30
30
|
attribute :properties, Idml::Elements::Properties, collection: true
|
|
31
31
|
attribute :transparency_setting, Idml::Elements::TransparencySetting
|
|
32
|
+
attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
|
|
33
|
+
attribute :anchored_object_setting,
|
|
34
|
+
Idml::Elements::AnchoredObjectSetting
|
|
32
35
|
attribute :image, Idml::Elements::Image, collection: true
|
|
33
36
|
|
|
34
37
|
xml do
|
|
@@ -50,6 +53,8 @@ module Idml
|
|
|
50
53
|
map_attribute "ItemLayer", to: :item_layer
|
|
51
54
|
map_element "Properties", to: :properties
|
|
52
55
|
map_element "TransparencySetting", to: :transparency_setting
|
|
56
|
+
map_element "TextWrapPreference", to: :text_wrap_preference
|
|
57
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
53
58
|
map_element "Image", to: :image
|
|
54
59
|
end
|
|
55
60
|
|
data/lib/idml/elements/path.rb
CHANGED
|
@@ -25,6 +25,9 @@ module Idml
|
|
|
25
25
|
attribute :item_layer, :string
|
|
26
26
|
attribute :properties, Idml::Elements::Properties, collection: true
|
|
27
27
|
attribute :transparency_setting, Idml::Elements::TransparencySetting
|
|
28
|
+
attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
|
|
29
|
+
attribute :anchored_object_setting,
|
|
30
|
+
Idml::Elements::AnchoredObjectSetting
|
|
28
31
|
|
|
29
32
|
xml do
|
|
30
33
|
root "Path"
|
|
@@ -41,6 +44,8 @@ module Idml
|
|
|
41
44
|
map_attribute "ItemLayer", to: :item_layer
|
|
42
45
|
map_element "Properties", to: :properties
|
|
43
46
|
map_element "TransparencySetting", to: :transparency_setting
|
|
47
|
+
map_element "TextWrapPreference", to: :text_wrap_preference
|
|
48
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def geometric_bounds
|
|
@@ -20,6 +20,9 @@ module Idml
|
|
|
20
20
|
attribute :item_layer, :string
|
|
21
21
|
attribute :properties, Idml::Elements::Properties, collection: true
|
|
22
22
|
attribute :transparency_setting, Idml::Elements::TransparencySetting
|
|
23
|
+
attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
|
|
24
|
+
attribute :anchored_object_setting,
|
|
25
|
+
Idml::Elements::AnchoredObjectSetting
|
|
23
26
|
attribute :image, Idml::Elements::Image, collection: true
|
|
24
27
|
|
|
25
28
|
xml do
|
|
@@ -39,6 +42,8 @@ module Idml
|
|
|
39
42
|
map_attribute "ItemLayer", to: :item_layer
|
|
40
43
|
map_element "Properties", to: :properties
|
|
41
44
|
map_element "TransparencySetting", to: :transparency_setting
|
|
45
|
+
map_element "TextWrapPreference", to: :text_wrap_preference
|
|
46
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
42
47
|
map_element "Image", to: :image
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -25,6 +25,8 @@ module Idml
|
|
|
25
25
|
attribute :properties, Idml::Elements::Properties, collection: true
|
|
26
26
|
attribute :transparency_setting, Idml::Elements::TransparencySetting
|
|
27
27
|
attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
|
|
28
|
+
attribute :anchored_object_setting,
|
|
29
|
+
Idml::Elements::AnchoredObjectSetting
|
|
28
30
|
attribute :image, Idml::Elements::Image, collection: true
|
|
29
31
|
|
|
30
32
|
xml do
|
|
@@ -48,6 +50,7 @@ module Idml
|
|
|
48
50
|
map_element "Properties", to: :properties
|
|
49
51
|
map_element "TransparencySetting", to: :transparency_setting
|
|
50
52
|
map_element "TextWrapPreference", to: :text_wrap_preference
|
|
53
|
+
map_element "AnchoredObjectSetting", to: :anchored_object_setting
|
|
51
54
|
map_element "Image", to: :image
|
|
52
55
|
end
|
|
53
56
|
|
|
@@ -312,6 +312,7 @@ module Idml
|
|
|
312
312
|
collection: true
|
|
313
313
|
attribute :content, Idml::Elements::Content, collection: true
|
|
314
314
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
315
|
+
attribute :footnote, Idml::Elements::Footnote, collection: true
|
|
315
316
|
|
|
316
317
|
xml do
|
|
317
318
|
root "Story"
|
|
@@ -676,6 +677,7 @@ module Idml
|
|
|
676
677
|
map_element "CharacterStyleRange", to: :character_style_range
|
|
677
678
|
map_element "Content", to: :content
|
|
678
679
|
map_element "XMLElement", to: :xml_element
|
|
680
|
+
map_element "Footnote", to: :footnote
|
|
679
681
|
end
|
|
680
682
|
|
|
681
683
|
def text_content
|
data/lib/idml/elements.rb
CHANGED
|
@@ -54,6 +54,7 @@ module Idml
|
|
|
54
54
|
"idml/elements/flex_layout_attribute_option"
|
|
55
55
|
autoload :Font, "idml/elements/font"
|
|
56
56
|
autoload :FontFamily, "idml/elements/font_family"
|
|
57
|
+
autoload :Footnote, "idml/elements/footnote"
|
|
57
58
|
autoload :FootnoteOption, "idml/elements/pref_footnote_option"
|
|
58
59
|
autoload :FrameFittingOption, "idml/elements/frame_fitting_option"
|
|
59
60
|
autoload :GraphicLine, "idml/elements/graphic_line"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Footnote semantics: story-scoped numbering, body-text marker
|
|
6
|
+
# synthesis, footnote-paragraph extraction, and the geometry of
|
|
7
|
+
# the bottom-of-frame footnote area (height reservation +
|
|
8
|
+
# separator rule). Emission of footnote text lines stays in
|
|
9
|
+
# TextFrameRenderer — it owns all canvas text drawing — while
|
|
10
|
+
# this module owns everything footnote-specific (MECE).
|
|
11
|
+
#
|
|
12
|
+
# Numbering is sequential per story, honoring FootnoteOption's
|
|
13
|
+
# StartAt / Prefix / Suffix when the package declares them.
|
|
14
|
+
# Document-wide continuous numbering and per-section restart
|
|
15
|
+
# are not modeled.
|
|
16
|
+
module Footnote
|
|
17
|
+
# One footnote collected during a frame's layout: its number
|
|
18
|
+
# and its extracted (already marker-prefixed) paragraphs.
|
|
19
|
+
Entry = Struct.new(:number, :paragraphs, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# A positioned footnote line plus the run it came from — the
|
|
22
|
+
# unit TextFrameRenderer emits.
|
|
23
|
+
PositionedRun = Struct.new(:line, :run, :font_size, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# Story-scoped sequential counter. Mutated as csr_runs walks
|
|
26
|
+
# the story's style ranges so footnotes number in document
|
|
27
|
+
# order across paragraphs.
|
|
28
|
+
class Counter
|
|
29
|
+
def initialize(start_at = nil)
|
|
30
|
+
@next = start_at || 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_number
|
|
34
|
+
value = @next
|
|
35
|
+
@next += 1
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULT_RULE_WEIGHT = 0.5
|
|
41
|
+
DEFAULT_RULE_GAP = 3.0
|
|
42
|
+
MARKER_TEXT_SEPARATOR = " "
|
|
43
|
+
|
|
44
|
+
# Returns the package's FootnoteOption (Preferences.xml), or
|
|
45
|
+
# nil when the package doesn't declare one.
|
|
46
|
+
def self.option(package)
|
|
47
|
+
preferences = package&.preferences
|
|
48
|
+
preferences&.footnote_option&.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.counter_for(option)
|
|
52
|
+
Counter.new(option&.start_at)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Marker text shown in the body text (and prefixed to the
|
|
56
|
+
# footnote's first paragraph): Prefix + number + Suffix.
|
|
57
|
+
def self.marker_text(number, option)
|
|
58
|
+
"#{option&.prefix}#{number}#{option&.suffix}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Builds the superscript marker run emitted into the body
|
|
62
|
+
# text where the footnote anchors. Inherits the base run's
|
|
63
|
+
# character styling so the marker matches its context; when
|
|
64
|
+
# the owning CSR has no text run, falls back to defaults.
|
|
65
|
+
def self.marker_run(number, base, paragraphs, option)
|
|
66
|
+
marker = base || StyleResolver::StyledRun.new(
|
|
67
|
+
point_size: StyleResolver::DEFAULT_POINT_SIZE,
|
|
68
|
+
)
|
|
69
|
+
marker = marker.dup
|
|
70
|
+
marker.text = marker_text(number, option)
|
|
71
|
+
marker.position = "Superscript"
|
|
72
|
+
marker.footnote_number = number
|
|
73
|
+
marker.footnote_paragraphs = paragraphs
|
|
74
|
+
marker
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Extracts the footnote's paragraphs via StyleResolver and
|
|
78
|
+
# prefixes the first paragraph's first run with the marker
|
|
79
|
+
# text ("1 ", "n2." …) so the footnote reads as numbered.
|
|
80
|
+
def self.extract(element, number, condition_filter: nil,
|
|
81
|
+
style_lookup: nil, option: nil)
|
|
82
|
+
paragraphs = StyleResolver.extract_container_paragraphs(
|
|
83
|
+
element, condition_filter: condition_filter,
|
|
84
|
+
style_lookup: style_lookup
|
|
85
|
+
)
|
|
86
|
+
marker = marker_text(number, option)
|
|
87
|
+
prepend_marker(paragraphs, marker) unless marker.empty?
|
|
88
|
+
paragraphs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.prepend_marker(paragraphs, marker)
|
|
92
|
+
return if paragraphs.empty?
|
|
93
|
+
return if paragraphs.first.runs.empty?
|
|
94
|
+
|
|
95
|
+
first_run = paragraphs.first.runs.first
|
|
96
|
+
first_run.text = "#{marker}#{MARKER_TEXT_SEPARATOR}#{first_run.text}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :prepend_marker
|
|
99
|
+
|
|
100
|
+
# Lays out all entries' paragraphs top-down starting at
|
|
101
|
+
# `top_y`. Returns `[positioned_runs, bottom_y]` where
|
|
102
|
+
# bottom_y is the cursor after the last line — the same walk
|
|
103
|
+
# drives both height measurement and rendering, so the
|
|
104
|
+
# reserved area always matches what gets drawn.
|
|
105
|
+
def self.layout_entries(entries, frame, font, top_y, option = nil)
|
|
106
|
+
positioned = []
|
|
107
|
+
cursor = top_y
|
|
108
|
+
wrap_width = TextEngine::VerticalLayout.wrap_width(frame)
|
|
109
|
+
|
|
110
|
+
entries.each_with_index do |entry, index|
|
|
111
|
+
cursor -= (option&.space_between || 0).to_f if index.positive?
|
|
112
|
+
entry.paragraphs.each do |paragraph|
|
|
113
|
+
cursor = layout_paragraph(
|
|
114
|
+
paragraph, frame, font, wrap_width, cursor, positioned
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
[positioned, cursor]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Shapes, wraps, and positions one footnote paragraph,
|
|
122
|
+
# appending its positioned lines. Returns the next cursor.
|
|
123
|
+
def self.layout_paragraph(paragraph, frame, font, wrap_width, cursor,
|
|
124
|
+
positioned)
|
|
125
|
+
paragraph.runs.each do |run|
|
|
126
|
+
size = run.point_size || StyleResolver::DEFAULT_POINT_SIZE
|
|
127
|
+
glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
|
|
128
|
+
size: size)
|
|
129
|
+
lines = TextEngine::LineBreaker.break(glyphs: glyphs,
|
|
130
|
+
frame_width: wrap_width)
|
|
131
|
+
lines.each do |line|
|
|
132
|
+
TextEngine::Justifier.justify(
|
|
133
|
+
line: line, frame_width: wrap_width,
|
|
134
|
+
alignment: paragraph.alignment || :left
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
leading = TextEngine::VerticalLayout.leading_for(
|
|
138
|
+
paragraph.auto_leading, size
|
|
139
|
+
)
|
|
140
|
+
block, cursor = TextEngine::VerticalLayout.layout_block(
|
|
141
|
+
lines: lines, frame: frame, font_size: size, leading: leading,
|
|
142
|
+
cursor_y: cursor
|
|
143
|
+
)
|
|
144
|
+
block.each do |line|
|
|
145
|
+
positioned << PositionedRun.new(line: line, run: run,
|
|
146
|
+
font_size: size)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
cursor
|
|
150
|
+
end
|
|
151
|
+
private_class_method :layout_paragraph
|
|
152
|
+
|
|
153
|
+
# Height to reserve at the frame bottom for the entries:
|
|
154
|
+
# separator gap + the measured paragraph stack.
|
|
155
|
+
def self.reserved_height(entries, font, frame, option = nil)
|
|
156
|
+
return 0.0 if entries.empty?
|
|
157
|
+
|
|
158
|
+
content_bottom = TextEngine::VerticalLayout.bottom_limit(frame)
|
|
159
|
+
_, bottom_y = layout_entries(entries, frame, font, content_bottom,
|
|
160
|
+
option)
|
|
161
|
+
content_bottom - bottom_y + rule_gap(option)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Vertical distance between the footnote area's top edge and
|
|
165
|
+
# the first footnote line — room for the separator rule.
|
|
166
|
+
def self.rule_gap(option)
|
|
167
|
+
option&.spacer || DEFAULT_RULE_GAP
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Emits the separator rule at the top of the footnote area.
|
|
171
|
+
# Honors FootnoteOption RuleOn / RuleLineWeight / RuleWidth /
|
|
172
|
+
# RuleLeftIndent; defaults approximate InDesign (0.5pt rule
|
|
173
|
+
# spanning the column).
|
|
174
|
+
def self.emit_separator(canvas, frame, y, option)
|
|
175
|
+
return if option&.rule_on == false
|
|
176
|
+
|
|
177
|
+
thickness = separator_weight(option)
|
|
178
|
+
return unless thickness.positive?
|
|
179
|
+
|
|
180
|
+
stroke_rule(canvas, separator_left(frame, option), y,
|
|
181
|
+
separator_width(frame, option), thickness)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def self.separator_weight(option)
|
|
185
|
+
(option&.rule_line_weight || DEFAULT_RULE_WEIGHT).to_f
|
|
186
|
+
end
|
|
187
|
+
private_class_method :separator_weight
|
|
188
|
+
|
|
189
|
+
def self.separator_left(frame, option)
|
|
190
|
+
TextEngine::VerticalLayout.frame_left(frame) +
|
|
191
|
+
(option&.rule_left_indent || 0).to_f
|
|
192
|
+
end
|
|
193
|
+
private_class_method :separator_left
|
|
194
|
+
|
|
195
|
+
def self.separator_width(frame, option)
|
|
196
|
+
option&.rule_width ||
|
|
197
|
+
TextEngine::VerticalLayout.wrap_width(frame)
|
|
198
|
+
end
|
|
199
|
+
private_class_method :separator_width
|
|
200
|
+
|
|
201
|
+
def self.stroke_rule(canvas, left, y, width, thickness)
|
|
202
|
+
canvas.stroke_color([:gray, 1.0])
|
|
203
|
+
canvas.line_width = thickness
|
|
204
|
+
canvas.move_to(left, y)
|
|
205
|
+
canvas.line_to(left + width, y)
|
|
206
|
+
canvas.stroke
|
|
207
|
+
end
|
|
208
|
+
private_class_method :stroke_rule
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -25,7 +25,6 @@ module Idml
|
|
|
25
25
|
# FontMetrics is available (no shaper → no wrap → rough output).
|
|
26
26
|
class TextFrameRenderer
|
|
27
27
|
DEFAULT_SIZE = 12.0
|
|
28
|
-
LEADING_FACTOR = 1.2
|
|
29
28
|
|
|
30
29
|
def self.render(canvas, context)
|
|
31
30
|
frame = context.item
|
|
@@ -36,6 +35,7 @@ module Idml
|
|
|
36
35
|
|
|
37
36
|
box = frame_box(frame, context.page_height)
|
|
38
37
|
render_inline_tables(canvas, story, box, context)
|
|
38
|
+
render_anchored_objects(canvas, story, context)
|
|
39
39
|
|
|
40
40
|
state = initial_chain_state(frame, story, context)
|
|
41
41
|
return unless state
|
|
@@ -72,7 +72,8 @@ module Idml
|
|
|
72
72
|
def self.fresh_state(story, context)
|
|
73
73
|
paragraphs = StyleResolver.extract_paragraphs(
|
|
74
74
|
story, condition_filter: context.condition_filter,
|
|
75
|
-
style_lookup: context.style_lookup
|
|
75
|
+
style_lookup: context.style_lookup,
|
|
76
|
+
footnote_option: Footnote.option(context.package)
|
|
76
77
|
)
|
|
77
78
|
return nil if paragraphs.empty?
|
|
78
79
|
|
|
@@ -102,14 +103,47 @@ module Idml
|
|
|
102
103
|
private_class_method :render_inline_tables
|
|
103
104
|
|
|
104
105
|
def self.tables_in_story(story)
|
|
106
|
+
story_csrs(story).flat_map(&:table).compact
|
|
107
|
+
end
|
|
108
|
+
private_class_method :tables_in_story
|
|
109
|
+
|
|
110
|
+
# All CharacterStyleRanges of a story, in document order.
|
|
111
|
+
# Shared by the story-level item discovery walks (tables,
|
|
112
|
+
# anchored objects).
|
|
113
|
+
def self.story_csrs(story)
|
|
105
114
|
inner = story&.inner
|
|
106
115
|
return [] unless inner
|
|
107
116
|
|
|
108
|
-
inner.paragraph_style_range.flat_map
|
|
109
|
-
psr.character_style_range.flat_map(&:table)
|
|
110
|
-
end.compact
|
|
117
|
+
inner.paragraph_style_range.flat_map(&:character_style_range)
|
|
111
118
|
end
|
|
112
|
-
private_class_method :
|
|
119
|
+
private_class_method :story_csrs
|
|
120
|
+
|
|
121
|
+
# Renders anchored page items embedded in the story flow
|
|
122
|
+
# (Story > PSR > CSR > Rectangle/Oval/Polygon/GraphicLine/
|
|
123
|
+
# Group/TextFrame — the IDML structure for anchored objects).
|
|
124
|
+
# Each item renders at its own stored geometry, which
|
|
125
|
+
# InDesign resolves to the anchored position when saving;
|
|
126
|
+
# AnchorType-specific text reflow is not simulated (the
|
|
127
|
+
# layout engine positions text independently either way).
|
|
128
|
+
def self.render_anchored_objects(canvas, story, context)
|
|
129
|
+
anchored_items_in_story(story).each do |item|
|
|
130
|
+
PageItemRenderer.render(canvas, context_for_item(context, item))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :render_anchored_objects
|
|
134
|
+
|
|
135
|
+
def self.anchored_items_in_story(story)
|
|
136
|
+
story_csrs(story).flat_map do |csr|
|
|
137
|
+
[csr.rectangle, csr.oval, csr.polygon,
|
|
138
|
+
csr.graphic_line, csr.group, csr.text_frame]
|
|
139
|
+
end.flatten.compact
|
|
140
|
+
end
|
|
141
|
+
private_class_method :anchored_items_in_story
|
|
142
|
+
|
|
143
|
+
def self.context_for_item(context, item)
|
|
144
|
+
RenderContext.new(**context.to_h, item: item)
|
|
145
|
+
end
|
|
146
|
+
private_class_method :context_for_item
|
|
113
147
|
|
|
114
148
|
def self.render_text(canvas, state, context, box)
|
|
115
149
|
font = context.font_metrics
|
|
@@ -175,19 +209,23 @@ module Idml
|
|
|
175
209
|
top_y = layout_frame.y + layout_frame.height -
|
|
176
210
|
(layout_frame.inset_top || 0)
|
|
177
211
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
212
|
+
footnotes = []
|
|
178
213
|
cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
|
|
179
214
|
char_cursor = state.char_cursor
|
|
180
215
|
|
|
181
216
|
if state.current_paragraph
|
|
182
217
|
cursor_y, char_cursor = resume_paragraph(
|
|
183
218
|
canvas, state, context, layout_frame, font,
|
|
184
|
-
cursor_y, bottom_limit, char_cursor
|
|
219
|
+
cursor_y, bottom_limit, char_cursor, footnotes
|
|
185
220
|
)
|
|
186
|
-
return if state.current_paragraph
|
|
187
221
|
end
|
|
188
222
|
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
unless state.current_paragraph
|
|
224
|
+
consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
225
|
+
cursor_y, bottom_limit, char_cursor, footnotes)
|
|
226
|
+
end
|
|
227
|
+
render_footnote_entries(canvas, footnotes, layout_frame,
|
|
228
|
+
context, font)
|
|
191
229
|
end
|
|
192
230
|
private_class_method :engine_render_single_column
|
|
193
231
|
|
|
@@ -212,19 +250,23 @@ module Idml
|
|
|
212
250
|
def self.render_column(canvas, state, context, col_frame, font)
|
|
213
251
|
top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
|
|
214
252
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
|
|
253
|
+
footnotes = []
|
|
215
254
|
cursor_y = top_y
|
|
216
255
|
char_cursor = state.char_cursor
|
|
217
256
|
|
|
218
257
|
if state.current_paragraph
|
|
219
258
|
cursor_y, char_cursor = resume_paragraph(
|
|
220
259
|
canvas, state, context, col_frame, font,
|
|
221
|
-
cursor_y, bottom_limit, char_cursor
|
|
260
|
+
cursor_y, bottom_limit, char_cursor, footnotes
|
|
222
261
|
)
|
|
223
|
-
return if state.current_paragraph
|
|
224
262
|
end
|
|
225
263
|
|
|
226
|
-
|
|
227
|
-
|
|
264
|
+
unless state.current_paragraph
|
|
265
|
+
consume_paragraphs(canvas, state, context, col_frame, font,
|
|
266
|
+
cursor_y, bottom_limit, char_cursor, footnotes)
|
|
267
|
+
end
|
|
268
|
+
render_footnote_entries(canvas, footnotes, col_frame,
|
|
269
|
+
context, font)
|
|
228
270
|
end
|
|
229
271
|
private_class_method :render_column
|
|
230
272
|
|
|
@@ -343,12 +385,13 @@ module Idml
|
|
|
343
385
|
private_class_method :space_before_after
|
|
344
386
|
|
|
345
387
|
def self.resume_paragraph(canvas, state, context, layout_frame, font,
|
|
346
|
-
cursor_y, bottom_limit, char_cursor
|
|
388
|
+
cursor_y, bottom_limit, char_cursor,
|
|
389
|
+
footnotes)
|
|
347
390
|
paragraph = state.current_paragraph
|
|
348
391
|
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
349
392
|
canvas, paragraph, state.runs_remaining, context,
|
|
350
393
|
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
351
|
-
first_paragraph: true
|
|
394
|
+
first_paragraph: true, footnotes: footnotes
|
|
352
395
|
)
|
|
353
396
|
if remaining_runs.any?
|
|
354
397
|
state.runs_remaining = remaining_runs
|
|
@@ -361,7 +404,8 @@ module Idml
|
|
|
361
404
|
private_class_method :resume_paragraph
|
|
362
405
|
|
|
363
406
|
def self.consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
364
|
-
cursor_y, bottom_limit, char_cursor
|
|
407
|
+
cursor_y, bottom_limit, char_cursor,
|
|
408
|
+
footnotes)
|
|
365
409
|
pending = 0
|
|
366
410
|
state.paragraphs.each do |paragraph|
|
|
367
411
|
break if cursor_y < bottom_limit
|
|
@@ -369,7 +413,7 @@ module Idml
|
|
|
369
413
|
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
370
414
|
canvas, paragraph, paragraph.runs, context,
|
|
371
415
|
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
372
|
-
first_paragraph: pending.zero
|
|
416
|
+
first_paragraph: pending.zero?, footnotes: footnotes
|
|
373
417
|
)
|
|
374
418
|
if remaining_runs.any?
|
|
375
419
|
state.current_paragraph = paragraph
|
|
@@ -392,7 +436,7 @@ module Idml
|
|
|
392
436
|
def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
|
|
393
437
|
layout_frame, font, cursor_y,
|
|
394
438
|
bottom_limit, char_cursor,
|
|
395
|
-
first_paragraph:)
|
|
439
|
+
first_paragraph:, footnotes:)
|
|
396
440
|
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
397
441
|
layout_frame, paragraph.right_indent || 0
|
|
398
442
|
)
|
|
@@ -411,7 +455,7 @@ module Idml
|
|
|
411
455
|
rendered, cursor_y, char_cursor = iterate_paragraph_runs(
|
|
412
456
|
canvas, paragraph, effective_runs, context, layout_frame,
|
|
413
457
|
font, wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
414
|
-
first_paragraph, para_attrs, dc_width, dc_lines
|
|
458
|
+
first_paragraph, para_attrs, dc_width, dc_lines, footnotes
|
|
415
459
|
)
|
|
416
460
|
|
|
417
461
|
remaining_runs = effective_runs[rendered..]
|
|
@@ -427,7 +471,7 @@ module Idml
|
|
|
427
471
|
layout_frame, font, wrap_width,
|
|
428
472
|
cursor_y, bottom_limit, char_cursor,
|
|
429
473
|
first_paragraph, para_attrs,
|
|
430
|
-
dc_width, dc_lines)
|
|
474
|
+
dc_width, dc_lines, footnotes)
|
|
431
475
|
rendered_count = 0
|
|
432
476
|
runs.each do |run|
|
|
433
477
|
break if cursor_y < bottom_limit
|
|
@@ -436,20 +480,92 @@ module Idml
|
|
|
436
480
|
rendered_count)
|
|
437
481
|
run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
|
|
438
482
|
dc_lines, rendered_count)
|
|
483
|
+
run_wrap -= text_wrap_overlap(context, layout_frame, cursor_y,
|
|
484
|
+
run)
|
|
439
485
|
positioned, next_y = layout_run(
|
|
440
486
|
canvas, run, paragraph, context, layout_frame, font,
|
|
441
487
|
run_wrap, cursor_y, space_before,
|
|
442
488
|
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
443
|
-
char_cursor
|
|
489
|
+
char_cursor, bottom_limit
|
|
444
490
|
)
|
|
445
491
|
char_cursor += positioned.length
|
|
446
492
|
cursor_y = next_y
|
|
447
493
|
rendered_count += 1
|
|
494
|
+
bottom_limit = register_footnote(footnotes, run, layout_frame,
|
|
495
|
+
font, context, bottom_limit)
|
|
448
496
|
end
|
|
449
497
|
[rendered_count, cursor_y, char_cursor]
|
|
450
498
|
end
|
|
451
499
|
private_class_method :iterate_paragraph_runs
|
|
452
500
|
|
|
501
|
+
# Collects the footnote anchored by a marker run and raises
|
|
502
|
+
# the effective bottom limit to reserve room for all
|
|
503
|
+
# footnotes collected so far. Pass-through for regular runs.
|
|
504
|
+
def self.register_footnote(footnotes, run, layout_frame, font,
|
|
505
|
+
context, bottom_limit)
|
|
506
|
+
return bottom_limit unless run.footnote_paragraphs
|
|
507
|
+
|
|
508
|
+
footnotes << Footnote::Entry.new(
|
|
509
|
+
number: run.footnote_number,
|
|
510
|
+
paragraphs: run.footnote_paragraphs,
|
|
511
|
+
)
|
|
512
|
+
option = Footnote.option(context.package)
|
|
513
|
+
frame_bottom = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
514
|
+
reserved = Footnote.reserved_height(footnotes, font, layout_frame,
|
|
515
|
+
option)
|
|
516
|
+
frame_bottom + reserved
|
|
517
|
+
end
|
|
518
|
+
private_class_method :register_footnote
|
|
519
|
+
|
|
520
|
+
# Renders the collected footnotes at the bottom of the
|
|
521
|
+
# frame: separator rule at the area's top edge, then the
|
|
522
|
+
# footnote paragraphs (marker-prefixed at extraction)
|
|
523
|
+
# flowing downward to the content bottom.
|
|
524
|
+
def self.render_footnote_entries(canvas, footnotes, layout_frame,
|
|
525
|
+
context, font)
|
|
526
|
+
return if footnotes.empty?
|
|
527
|
+
|
|
528
|
+
option = Footnote.option(context.package)
|
|
529
|
+
content_bottom = TextEngine::VerticalLayout.bottom_limit(
|
|
530
|
+
layout_frame,
|
|
531
|
+
)
|
|
532
|
+
area_top = content_bottom +
|
|
533
|
+
Footnote.reserved_height(footnotes, font, layout_frame, option)
|
|
534
|
+
|
|
535
|
+
Footnote.emit_separator(canvas, layout_frame, area_top, option)
|
|
536
|
+
positioned, = Footnote.layout_entries(
|
|
537
|
+
footnotes, layout_frame, font,
|
|
538
|
+
area_top - Footnote.rule_gap(option), option
|
|
539
|
+
)
|
|
540
|
+
positioned.each do |item|
|
|
541
|
+
next if item.line.y < content_bottom
|
|
542
|
+
|
|
543
|
+
emit_line(canvas, item.line, item.run, context, item.font_size)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
private_class_method :render_footnote_entries
|
|
547
|
+
|
|
548
|
+
# Computes the text-wrap overlap for a run at its current y
|
|
549
|
+
# position. Uses the run's point_size as the line height
|
|
550
|
+
# approximation. Returns 0.0 when no resolver is wired or no
|
|
551
|
+
# contours overlap. Per-run approximation: all lines in the
|
|
552
|
+
# run get the same reduced width (correct when the run is
|
|
553
|
+
# fully inside or outside the contour; approximate when it
|
|
554
|
+
# spans the boundary).
|
|
555
|
+
def self.text_wrap_overlap(context, layout_frame, cursor_y, run)
|
|
556
|
+
resolver = context.text_wrap_resolver
|
|
557
|
+
return 0.0 unless resolver
|
|
558
|
+
|
|
559
|
+
size = run.point_size || DEFAULT_SIZE
|
|
560
|
+
frame_x = layout_frame.x + (layout_frame.inset_left || 0)
|
|
561
|
+
frame_right = layout_frame.x + layout_frame.width -
|
|
562
|
+
(layout_frame.inset_right || 0)
|
|
563
|
+
overlap = resolver.overlap_width(cursor_y, size,
|
|
564
|
+
frame_x, frame_right)
|
|
565
|
+
[overlap, 0.0].max
|
|
566
|
+
end
|
|
567
|
+
private_class_method :text_wrap_overlap
|
|
568
|
+
|
|
453
569
|
# Strips drop cap characters from the first run so they don't
|
|
454
570
|
# render twice (once as the enlarged drop cap, once as normal
|
|
455
571
|
# text). Returns a new run list with the first run's text
|
|
@@ -571,7 +687,7 @@ module Idml
|
|
|
571
687
|
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
572
688
|
font, wrap_width, cursor_y, space_before,
|
|
573
689
|
first_line_indent, left_indent,
|
|
574
|
-
char_cursor)
|
|
690
|
+
char_cursor, bottom_limit)
|
|
575
691
|
size = run.point_size || DEFAULT_SIZE
|
|
576
692
|
glyphs = TextEngine::Shaper.shape(
|
|
577
693
|
text: run.text, font: font, size: size,
|
|
@@ -598,7 +714,6 @@ module Idml
|
|
|
598
714
|
left_indent: left_indent,
|
|
599
715
|
)
|
|
600
716
|
|
|
601
|
-
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
602
717
|
positioned.each do |line|
|
|
603
718
|
break if line.y < bottom_limit
|
|
604
719
|
|
|
@@ -641,10 +756,7 @@ module Idml
|
|
|
641
756
|
private_class_method :emit_line
|
|
642
757
|
|
|
643
758
|
def self.leading_for(paragraph, size)
|
|
644
|
-
|
|
645
|
-
return size * LEADING_FACTOR unless explicit&.positive?
|
|
646
|
-
|
|
647
|
-
size * explicit
|
|
759
|
+
TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
|
|
648
760
|
end
|
|
649
761
|
private_class_method :leading_for
|
|
650
762
|
|
|
@@ -39,6 +39,7 @@ module Idml
|
|
|
39
39
|
chain_controller: StoryChainController.new,
|
|
40
40
|
condition_filter: @condition_filter,
|
|
41
41
|
style_lookup: @style_lookup,
|
|
42
|
+
text_wrap_resolver: build_text_wrap_resolver(spread, page_height),
|
|
42
43
|
}
|
|
43
44
|
images_by_parent = group_images_by_parent(image_refs)
|
|
44
45
|
|
|
@@ -144,6 +145,10 @@ module Idml
|
|
|
144
145
|
|
|
145
146
|
ColorResolver.new(@package.graphic)
|
|
146
147
|
end
|
|
148
|
+
|
|
149
|
+
def build_text_wrap_resolver(spread, page_height)
|
|
150
|
+
TextWrapResolver.build(spread, page_height: page_height)
|
|
151
|
+
end
|
|
147
152
|
end
|
|
148
153
|
end
|
|
149
154
|
end
|
|
@@ -18,7 +18,9 @@ module Idml
|
|
|
18
18
|
class StyleResolver
|
|
19
19
|
# Character-level attributes carried per run. Defaults are
|
|
20
20
|
# applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
|
|
21
|
-
# itself stores only what the CSR declares.
|
|
21
|
+
# itself stores only what the CSR declares. `footnote_number`
|
|
22
|
+
# and `footnote_paragraphs` are set only on synthesized
|
|
23
|
+
# footnote-marker runs (see Render::Footnote).
|
|
22
24
|
StyledRun = Struct.new(
|
|
23
25
|
:text,
|
|
24
26
|
:font_style,
|
|
@@ -39,6 +41,8 @@ module Idml
|
|
|
39
41
|
:horizontal_scale,
|
|
40
42
|
:vertical_scale,
|
|
41
43
|
:baseline_shift,
|
|
44
|
+
:footnote_number,
|
|
45
|
+
:footnote_paragraphs,
|
|
42
46
|
keyword_init: true,
|
|
43
47
|
)
|
|
44
48
|
|
|
@@ -98,27 +102,48 @@ module Idml
|
|
|
98
102
|
|
|
99
103
|
# Returns the flat run list for a story. Each run carries only
|
|
100
104
|
# character-level attributes. Paragraph boundaries are lost.
|
|
105
|
+
# Footnote markers number sequentially across the story.
|
|
101
106
|
def self.extract_runs(story)
|
|
102
107
|
return [] unless story&.inner
|
|
103
108
|
|
|
109
|
+
counter = Footnote.counter_for(nil)
|
|
104
110
|
story.inner.paragraph_style_range.flat_map do |psr|
|
|
105
|
-
csr_runs(psr)
|
|
111
|
+
csr_runs(psr, footnote_counter: counter)
|
|
106
112
|
end
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
# Returns the paragraph list for a story. Each paragraph groups
|
|
110
116
|
# its runs and carries the paragraph-level attributes from the
|
|
111
117
|
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
112
|
-
# skipped.
|
|
118
|
+
# skipped. Footnotes anchored in the story emit superscript
|
|
119
|
+
# marker runs, numbered sequentially from the FootnoteOption's
|
|
120
|
+
# StartAt (default 1).
|
|
113
121
|
def self.extract_paragraphs(story, condition_filter: nil,
|
|
114
|
-
style_lookup: nil)
|
|
122
|
+
style_lookup: nil, footnote_option: nil)
|
|
115
123
|
return [] unless story&.inner
|
|
116
124
|
|
|
117
|
-
|
|
125
|
+
extract_container_paragraphs(
|
|
126
|
+
story.inner,
|
|
127
|
+
condition_filter: condition_filter, style_lookup: style_lookup,
|
|
128
|
+
footnote_option: footnote_option
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extracts paragraphs from any container exposing
|
|
133
|
+
# `paragraph_style_range` (StoryInner, Elements::Footnote).
|
|
134
|
+
# Footnote-counter state is shared across the whole walk so
|
|
135
|
+
# footnotes number in document order.
|
|
136
|
+
def self.extract_container_paragraphs(container, condition_filter: nil,
|
|
137
|
+
style_lookup: nil,
|
|
138
|
+
footnote_option: nil)
|
|
139
|
+
counter = Footnote.counter_for(footnote_option)
|
|
140
|
+
container.paragraph_style_range.filter_map do |psr|
|
|
118
141
|
next unless condition_visible?(psr, condition_filter)
|
|
119
142
|
|
|
120
143
|
runs = csr_runs(psr, condition_filter: condition_filter,
|
|
121
|
-
style_lookup: style_lookup
|
|
144
|
+
style_lookup: style_lookup,
|
|
145
|
+
footnote_counter: counter,
|
|
146
|
+
footnote_option: footnote_option)
|
|
122
147
|
next if runs.empty?
|
|
123
148
|
|
|
124
149
|
paragraph = Paragraph.new(
|
|
@@ -177,43 +202,73 @@ module Idml
|
|
|
177
202
|
end
|
|
178
203
|
end
|
|
179
204
|
|
|
180
|
-
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
|
|
206
|
+
footnote_counter: nil, footnote_option: nil)
|
|
207
|
+
counter = footnote_counter || Footnote.counter_for(footnote_option)
|
|
208
|
+
psr.character_style_range.flat_map do |csr|
|
|
209
|
+
next [] unless condition_visible?(csr, condition_filter)
|
|
183
210
|
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
csr_content_runs(csr, condition_filter: condition_filter,
|
|
212
|
+
style_lookup: style_lookup,
|
|
213
|
+
footnote_counter: counter,
|
|
214
|
+
footnote_option: footnote_option)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
:underline_weight),
|
|
204
|
-
strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
|
|
205
|
-
strike_through_offset: resolve_csr(style_lookup, csr,
|
|
206
|
-
:strike_through_offset),
|
|
207
|
-
strike_through_weight: resolve_csr(style_lookup, csr,
|
|
208
|
-
:strike_through_weight),
|
|
209
|
-
horizontal_scale: resolve_csr(style_lookup, csr,
|
|
210
|
-
:horizontal_scale),
|
|
211
|
-
vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
|
|
212
|
-
baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
|
|
218
|
+
# Builds the runs for one CSR: its text run (when non-empty)
|
|
219
|
+
# plus one superscript marker run per anchored Footnote. A CSR
|
|
220
|
+
# holding only a footnote still emits its marker.
|
|
221
|
+
def self.csr_content_runs(csr, condition_filter:, style_lookup:,
|
|
222
|
+
footnote_counter:, footnote_option:)
|
|
223
|
+
text = csr.text_content
|
|
224
|
+
runs = []
|
|
225
|
+
unless text.nil? || text.empty?
|
|
226
|
+
runs << text_run(csr, style_lookup: style_lookup)
|
|
227
|
+
end
|
|
228
|
+
csr.footnote.each do |element|
|
|
229
|
+
number = footnote_counter.next_number
|
|
230
|
+
paragraphs = Footnote.extract(
|
|
231
|
+
element, number, condition_filter: condition_filter,
|
|
232
|
+
style_lookup: style_lookup,
|
|
233
|
+
option: footnote_option
|
|
213
234
|
)
|
|
235
|
+
runs << Footnote.marker_run(number, runs.last, paragraphs,
|
|
236
|
+
footnote_option)
|
|
214
237
|
end
|
|
238
|
+
runs
|
|
239
|
+
end
|
|
240
|
+
private_class_method :csr_content_runs
|
|
241
|
+
|
|
242
|
+
def self.text_run(csr, style_lookup:)
|
|
243
|
+
StyledRun.new(
|
|
244
|
+
text: csr.text_content,
|
|
245
|
+
font_style: resolve_csr(style_lookup, csr, :font_style),
|
|
246
|
+
point_size: resolve_csr(style_lookup, csr, :point_size) ||
|
|
247
|
+
DEFAULT_POINT_SIZE,
|
|
248
|
+
fill_color: resolve_csr(style_lookup, csr, :fill_color),
|
|
249
|
+
fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
|
|
250
|
+
applied_font: resolve_csr(style_lookup, csr, :applied_font),
|
|
251
|
+
alignment: alignment_for(csr),
|
|
252
|
+
tracking: resolve_csr(style_lookup, csr, :tracking),
|
|
253
|
+
capitalization: resolve_csr(style_lookup, csr, :capitalization),
|
|
254
|
+
position: resolve_csr(style_lookup, csr, :position),
|
|
255
|
+
underline: resolve_csr(style_lookup, csr, :underline),
|
|
256
|
+
underline_offset: resolve_csr(style_lookup, csr,
|
|
257
|
+
:underline_offset),
|
|
258
|
+
underline_weight: resolve_csr(style_lookup, csr,
|
|
259
|
+
:underline_weight),
|
|
260
|
+
strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
|
|
261
|
+
strike_through_offset: resolve_csr(style_lookup, csr,
|
|
262
|
+
:strike_through_offset),
|
|
263
|
+
strike_through_weight: resolve_csr(style_lookup, csr,
|
|
264
|
+
:strike_through_weight),
|
|
265
|
+
horizontal_scale: resolve_csr(style_lookup, csr,
|
|
266
|
+
:horizontal_scale),
|
|
267
|
+
vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
|
|
268
|
+
baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
|
|
269
|
+
)
|
|
215
270
|
end
|
|
216
|
-
private_class_method :
|
|
271
|
+
private_class_method :text_run
|
|
217
272
|
|
|
218
273
|
# Resolves a paragraph-level attribute from PSR or its
|
|
219
274
|
# referenced ParagraphStyle. When style_lookup is nil,
|
|
@@ -66,16 +66,35 @@ module Idml
|
|
|
66
66
|
end
|
|
67
67
|
private_class_method :contour_for
|
|
68
68
|
|
|
69
|
+
# Page item types that can declare TextWrapPreference per the
|
|
70
|
+
# Spread schema. Used to guard the attribute read — querying
|
|
71
|
+
# non-shape items (Page, Link) would raise NoMethodError.
|
|
72
|
+
WRAPPABLE_TYPES = [
|
|
73
|
+
Idml::Elements::Rectangle,
|
|
74
|
+
Idml::Elements::Oval,
|
|
75
|
+
Idml::Elements::Polygon,
|
|
76
|
+
Idml::Elements::GraphicLine,
|
|
77
|
+
Idml::Elements::Path,
|
|
78
|
+
Idml::Elements::Group,
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
69
81
|
def self.wrap_preference(item)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
return nil unless wrappable?(item)
|
|
83
|
+
|
|
84
|
+
pref = item.text_wrap_preference
|
|
85
|
+
return nil if pref.nil? ||
|
|
86
|
+
pref.text_wrap_mode.nil? ||
|
|
73
87
|
pref.text_wrap_mode == "None"
|
|
74
88
|
|
|
75
89
|
pref
|
|
76
90
|
end
|
|
77
91
|
private_class_method :wrap_preference
|
|
78
92
|
|
|
93
|
+
def self.wrappable?(item)
|
|
94
|
+
WRAPPABLE_TYPES.any? { |type| item.is_a?(type) }
|
|
95
|
+
end
|
|
96
|
+
private_class_method :wrappable?
|
|
97
|
+
|
|
79
98
|
def self.bounding_box_mode?(pref)
|
|
80
99
|
pref.text_wrap_mode == "BoundingBox"
|
|
81
100
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Idml
|
|
|
22
22
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
23
23
|
autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
|
|
24
24
|
autoload :TextWrapResolver, "#{__dir__}/render/text_wrap_resolver"
|
|
25
|
+
autoload :Footnote, "#{__dir__}/render/footnote"
|
|
25
26
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
26
27
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
27
28
|
autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
|
|
@@ -54,6 +54,17 @@ module Idml
|
|
|
54
54
|
frame.y + (frame.inset_bottom || 0)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Leading for a text block: AutoLeading × size when the
|
|
58
|
+
# paragraph declares a positive AutoLeading, else the default
|
|
59
|
+
# factor. Shared by the frame renderer and the footnote
|
|
60
|
+
# layout so both stack lines identically.
|
|
61
|
+
def self.leading_for(auto_leading, font_size)
|
|
62
|
+
return font_size * DEFAULT_LEADING_FACTOR unless
|
|
63
|
+
auto_leading&.positive?
|
|
64
|
+
|
|
65
|
+
font_size * auto_leading
|
|
66
|
+
end
|
|
67
|
+
|
|
57
68
|
# Effective wrap width after subtracting insets and indents.
|
|
58
69
|
def self.wrap_width(frame, right_indent = 0)
|
|
59
70
|
width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
|
|
@@ -63,7 +74,6 @@ module Idml
|
|
|
63
74
|
def self.frame_left(frame)
|
|
64
75
|
frame.x + (frame.inset_left || 0)
|
|
65
76
|
end
|
|
66
|
-
private_class_method :frame_left
|
|
67
77
|
end
|
|
68
78
|
end
|
|
69
79
|
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.8.
|
|
4
|
+
version: 0.8.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -296,6 +296,7 @@ files:
|
|
|
296
296
|
- lib/idml/elements/flex_layout_attribute_option.rb
|
|
297
297
|
- lib/idml/elements/font.rb
|
|
298
298
|
- lib/idml/elements/font_family.rb
|
|
299
|
+
- lib/idml/elements/footnote.rb
|
|
299
300
|
- lib/idml/elements/frame_fitting_option.rb
|
|
300
301
|
- lib/idml/elements/geometry_path_type.rb
|
|
301
302
|
- lib/idml/elements/gradient.rb
|
|
@@ -441,6 +442,7 @@ files:
|
|
|
441
442
|
- lib/idml/render/drop_cap.rb
|
|
442
443
|
- lib/idml/render/font_reference_resolver.rb
|
|
443
444
|
- lib/idml/render/font_setup.rb
|
|
445
|
+
- lib/idml/render/footnote.rb
|
|
444
446
|
- lib/idml/render/gradient_resolver.rb
|
|
445
447
|
- lib/idml/render/hyperlink_emitter.rb
|
|
446
448
|
- lib/idml/render/hyperlink_resolver.rb
|