coradoc-docx 0.1.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 +7 -0
- data/README.adoc +164 -0
- data/lib/coradoc/docx/transform/context.rb +72 -0
- data/lib/coradoc/docx/transform/from_core_model.rb +577 -0
- data/lib/coradoc/docx/transform/numbering_resolver.rb +127 -0
- data/lib/coradoc/docx/transform/ordered_content.rb +95 -0
- data/lib/coradoc/docx/transform/rule.rb +57 -0
- data/lib/coradoc/docx/transform/rule_registry.rb +60 -0
- data/lib/coradoc/docx/transform/rules/bookmark_rule.rb +34 -0
- data/lib/coradoc/docx/transform/rules/break_rule.rb +30 -0
- data/lib/coradoc/docx/transform/rules/footnote_rule.rb +27 -0
- data/lib/coradoc/docx/transform/rules/heading_rule.rb +53 -0
- data/lib/coradoc/docx/transform/rules/hyperlink_rule.rb +58 -0
- data/lib/coradoc/docx/transform/rules/image_rule.rb +125 -0
- data/lib/coradoc/docx/transform/rules/list_item_rule.rb +47 -0
- data/lib/coradoc/docx/transform/rules/math_rule.rb +82 -0
- data/lib/coradoc/docx/transform/rules/paragraph_rule.rb +65 -0
- data/lib/coradoc/docx/transform/rules/proof_error_rule.rb +25 -0
- data/lib/coradoc/docx/transform/rules/run_rule.rb +189 -0
- data/lib/coradoc/docx/transform/rules/simple_field_rule.rb +87 -0
- data/lib/coradoc/docx/transform/rules/structured_document_tag_rule.rb +36 -0
- data/lib/coradoc/docx/transform/rules/table_rule.rb +85 -0
- data/lib/coradoc/docx/transform/rules/text_rule.rb +25 -0
- data/lib/coradoc/docx/transform/style_resolver.rb +249 -0
- data/lib/coradoc/docx/transform/to_core_model.rb +340 -0
- data/lib/coradoc/docx/transform.rb +38 -0
- data/lib/coradoc/docx/version.rb +7 -0
- data/lib/coradoc/docx.rb +99 -0
- metadata +155 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Docx
|
|
5
|
+
module Transform
|
|
6
|
+
# Resolves paragraph and run styles to semantic roles.
|
|
7
|
+
#
|
|
8
|
+
# OOXML paragraphs don't have explicit element types. Instead, their
|
|
9
|
+
# meaning is determined by style references (e.g., "Heading1" → section)
|
|
10
|
+
# or by formatting properties (e.g., numPr → list item).
|
|
11
|
+
#
|
|
12
|
+
# StyleResolver centralizes this detection so HeadingRule, ListItemRule,
|
|
13
|
+
# and ParagraphRule don't duplicate the logic.
|
|
14
|
+
#
|
|
15
|
+
# The style map is built from the Uniword StylesConfiguration by walking
|
|
16
|
+
# all style definitions and their basedOn chains.
|
|
17
|
+
class StyleResolver
|
|
18
|
+
HEADING_PATTERN = /^(heading|heading|h)\s*(\d+)$/i
|
|
19
|
+
QUOTE_PATTERN = /\bquote\b/i
|
|
20
|
+
CODE_PATTERN = /\b(code|source|listing)\b/i
|
|
21
|
+
LITERAL_PATTERN = /\bliteral\b/i
|
|
22
|
+
EXAMPLE_PATTERN = /\bexample\b/i
|
|
23
|
+
|
|
24
|
+
# @param styles_configuration [Object, nil] Uniword styles configuration
|
|
25
|
+
def initialize(styles_configuration)
|
|
26
|
+
@config = styles_configuration
|
|
27
|
+
@style_map = build_style_map(styles_configuration)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Determine the semantic role of a paragraph
|
|
31
|
+
#
|
|
32
|
+
# @param paragraph [Uniword::Wordprocessingml::Paragraph]
|
|
33
|
+
# @return [Symbol] :heading, :list_item, :quote, :source, :literal,
|
|
34
|
+
# :example, or :paragraph
|
|
35
|
+
def semantic_role(paragraph)
|
|
36
|
+
return :heading if heading?(paragraph)
|
|
37
|
+
return :list_item if list_item?(paragraph)
|
|
38
|
+
|
|
39
|
+
style_role = role_from_style(paragraph)
|
|
40
|
+
return style_role if style_role
|
|
41
|
+
|
|
42
|
+
:paragraph
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if paragraph is a heading
|
|
46
|
+
# @param paragraph [Uniword::Wordprocessingml::Paragraph]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def heading?(paragraph)
|
|
49
|
+
return false unless paragraph.properties
|
|
50
|
+
|
|
51
|
+
style_name = resolve_style_name(paragraph)
|
|
52
|
+
return true if style_name && HEADING_PATTERN.match?(style_name)
|
|
53
|
+
|
|
54
|
+
ol = paragraph.properties.outline_level
|
|
55
|
+
if ol
|
|
56
|
+
ol_level = ol.is_a?(Uniword::Wordprocessingml::OutlineLevel) ? ol.value.to_i : ol.to_i
|
|
57
|
+
return true if ol_level.positive?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
style = find_style_for_paragraph(paragraph)
|
|
61
|
+
if style&.outline_level
|
|
62
|
+
ol_val = style.outline_level
|
|
63
|
+
ol_val = ol_val.is_a?(Uniword::Wordprocessingml::OutlineLevel) ? ol_val.value.to_i : ol_val.to_i
|
|
64
|
+
return true if ol_val.positive?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get heading level (1-6) or nil
|
|
71
|
+
# @param paragraph [Uniword::Wordprocessingml::Paragraph]
|
|
72
|
+
# @return [Integer, nil]
|
|
73
|
+
def heading_level(paragraph)
|
|
74
|
+
style_name = resolve_style_name(paragraph)
|
|
75
|
+
if style_name
|
|
76
|
+
match = HEADING_PATTERN.match(style_name)
|
|
77
|
+
return match[2].to_i if match
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check outline_level on paragraph properties
|
|
81
|
+
ol = paragraph.properties&.outline_level
|
|
82
|
+
if ol
|
|
83
|
+
level = ol.is_a?(Uniword::Wordprocessingml::OutlineLevel) ? ol.value.to_i : ol.to_i
|
|
84
|
+
return level if level.positive?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if paragraph is a list item
|
|
91
|
+
# @param paragraph [Uniword::Wordprocessingml::Paragraph]
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def list_item?(paragraph)
|
|
94
|
+
return false unless paragraph.properties
|
|
95
|
+
|
|
96
|
+
num_id = paragraph.properties.num_id
|
|
97
|
+
num_id.to_i.positive?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check if paragraph has a specific role based on style name
|
|
101
|
+
# @param paragraph [Uniword::Wordprocessingml::Paragraph]
|
|
102
|
+
# @return [Symbol, nil]
|
|
103
|
+
def role_from_style(paragraph)
|
|
104
|
+
style_name = resolve_style_name(paragraph)
|
|
105
|
+
return nil unless style_name
|
|
106
|
+
|
|
107
|
+
case style_name
|
|
108
|
+
when QUOTE_PATTERN then :quote
|
|
109
|
+
when CODE_PATTERN then :source
|
|
110
|
+
when LITERAL_PATTERN then :literal
|
|
111
|
+
when EXAMPLE_PATTERN then :example
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Detect semantic role of a run based on its rStyle
|
|
116
|
+
# @param run [Uniword::Wordprocessingml::Run]
|
|
117
|
+
# @return [Symbol, nil]
|
|
118
|
+
def run_semantic_role(run)
|
|
119
|
+
return nil unless run.properties
|
|
120
|
+
return nil unless run.properties.style
|
|
121
|
+
|
|
122
|
+
style_name = resolve_run_style_name(run)
|
|
123
|
+
return nil unless style_name
|
|
124
|
+
|
|
125
|
+
case style_name
|
|
126
|
+
when /\b(code|verbatim|teletype|keyboard)\b/i then :monospace
|
|
127
|
+
when /\bstrong\b/i then :bold
|
|
128
|
+
when /\b(emphasis|em)\b/i then :italic
|
|
129
|
+
when /\bcitation\b/i then :italic
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def resolve_style_name(paragraph)
|
|
136
|
+
style_ref = paragraph.properties&.style
|
|
137
|
+
return nil unless style_ref
|
|
138
|
+
|
|
139
|
+
value = style_ref.is_a?(Uniword::Wordprocessingml::PStyle) ? style_ref.val : style_ref.to_s
|
|
140
|
+
return nil unless value
|
|
141
|
+
|
|
142
|
+
mapped = @style_map[value]
|
|
143
|
+
return mapped if mapped
|
|
144
|
+
|
|
145
|
+
value
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolve_run_style_name(run)
|
|
149
|
+
style_ref = run.properties.style
|
|
150
|
+
return nil unless style_ref
|
|
151
|
+
|
|
152
|
+
value = style_ref.is_a?(Uniword::Wordprocessingml::PStyle) ? style_ref.val : style_ref.to_s
|
|
153
|
+
return nil unless value
|
|
154
|
+
|
|
155
|
+
mapped = @style_map[value]
|
|
156
|
+
mapped || value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def find_style_for_paragraph(paragraph)
|
|
160
|
+
return nil unless @config
|
|
161
|
+
|
|
162
|
+
style_id = style_id_from_paragraph(paragraph)
|
|
163
|
+
return nil unless style_id
|
|
164
|
+
|
|
165
|
+
return unless @config.is_a?(Uniword::Wordprocessingml::StylesConfiguration)
|
|
166
|
+
|
|
167
|
+
@config.style_by_id(style_id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def style_id_from_paragraph(paragraph)
|
|
171
|
+
style_ref = paragraph.properties&.style
|
|
172
|
+
return nil unless style_ref
|
|
173
|
+
|
|
174
|
+
style_ref.is_a?(Uniword::Wordprocessingml::PStyle) ? style_ref.val : style_ref.to_s
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_style_map(config)
|
|
178
|
+
return {} unless config
|
|
179
|
+
return {} unless config.is_a?(Uniword::Wordprocessingml::StylesConfiguration)
|
|
180
|
+
|
|
181
|
+
map = {}
|
|
182
|
+
config.styles.each do |style|
|
|
183
|
+
id = style.styleId
|
|
184
|
+
name = extract_style_name(style)
|
|
185
|
+
next unless id && name
|
|
186
|
+
|
|
187
|
+
map[id] = name
|
|
188
|
+
|
|
189
|
+
if heading_by_based_on?(config, style)
|
|
190
|
+
level = heading_level_from_chain(config, style)
|
|
191
|
+
map[id] = "Heading#{level}"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
map
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def extract_style_name(style)
|
|
199
|
+
sn = style.style_name
|
|
200
|
+
return sn if sn
|
|
201
|
+
|
|
202
|
+
name = style.name
|
|
203
|
+
return nil unless name
|
|
204
|
+
|
|
205
|
+
name.is_a?(Uniword::Wordprocessingml::StyleName) ? name.val.to_s : name.to_s
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def heading_by_based_on?(config, style)
|
|
209
|
+
based_on = style.based_on
|
|
210
|
+
return false unless based_on
|
|
211
|
+
|
|
212
|
+
visited = Set.new
|
|
213
|
+
current = style
|
|
214
|
+
while current && !visited.include?(current.styleId)
|
|
215
|
+
visited << current.styleId
|
|
216
|
+
parent_id = current.based_on
|
|
217
|
+
return true if parent_id && HEADING_PATTERN.match?(parent_id)
|
|
218
|
+
|
|
219
|
+
break unless parent_id
|
|
220
|
+
|
|
221
|
+
current = config.styles.find { |s| s.styleId == parent_id }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def heading_level_from_chain(config, style)
|
|
228
|
+
visited = Set.new
|
|
229
|
+
current = style
|
|
230
|
+
while current && !visited.include?(current.styleId)
|
|
231
|
+
visited << current.styleId
|
|
232
|
+
name = extract_style_name(current)
|
|
233
|
+
if name
|
|
234
|
+
match = HEADING_PATTERN.match(name)
|
|
235
|
+
return match[2].to_i if match
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
parent_id = current.based_on
|
|
239
|
+
break unless parent_id
|
|
240
|
+
|
|
241
|
+
current = config.styles.find { |s| s.styleId == parent_id }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
1
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Docx
|
|
5
|
+
module Transform
|
|
6
|
+
# Orchestrator for OOXML → CoreModel transformation.
|
|
7
|
+
#
|
|
8
|
+
# Walks a Uniword::Wordprocessingml::DocumentRoot tree and dispatches
|
|
9
|
+
# to registered transform rules. Handles:
|
|
10
|
+
#
|
|
11
|
+
# - Style-based heading detection (via StyleResolver)
|
|
12
|
+
# - List grouping (consecutive numPr paragraphs → single ListBlock)
|
|
13
|
+
# - Footnote content collection
|
|
14
|
+
# - Image reference tracking
|
|
15
|
+
# - Bookmark ID propagation
|
|
16
|
+
#
|
|
17
|
+
# Dispatch strategy:
|
|
18
|
+
# - HeadingRule and ListItemRule are dispatched directly by the
|
|
19
|
+
# orchestrator (they need context for style resolution).
|
|
20
|
+
# - All other element types are dispatched via RuleRegistry.
|
|
21
|
+
#
|
|
22
|
+
# @example Transform a DOCX document
|
|
23
|
+
# doc = Uniword::DocumentFactory.from_file("input.docx")
|
|
24
|
+
# core = ToCoreModel.transform(doc)
|
|
25
|
+
# # => Coradoc::CoreModel::StructuralElement
|
|
26
|
+
class ToCoreModel
|
|
27
|
+
class << self
|
|
28
|
+
def transform(document)
|
|
29
|
+
new.transform(document)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def transform(document)
|
|
34
|
+
registry = build_registry
|
|
35
|
+
|
|
36
|
+
context = Context.new(
|
|
37
|
+
styles_configuration: document.styles_configuration,
|
|
38
|
+
numbering_configuration: document.numbering_configuration,
|
|
39
|
+
footnotes: collect_footnotes(document),
|
|
40
|
+
registry: registry
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@heading_rule = Rules::HeadingRule.new
|
|
44
|
+
@list_item_rule = Rules::ListItemRule.new
|
|
45
|
+
|
|
46
|
+
body = document.body
|
|
47
|
+
doc_title = extract_document_title(document, context)
|
|
48
|
+
children = transform_elements(body, context)
|
|
49
|
+
|
|
50
|
+
# If the first child is an H1 matching the doc title, skip the
|
|
51
|
+
# duplicate — the document title already captures it
|
|
52
|
+
if doc_title && children.first.is_a?(Coradoc::CoreModel::StructuralElement) &&
|
|
53
|
+
children.first.section? &&
|
|
54
|
+
children.first.title == doc_title &&
|
|
55
|
+
children.first.level == 1
|
|
56
|
+
children.shift
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
doc = Coradoc::CoreModel::StructuralElement.new(
|
|
60
|
+
element_type: 'document',
|
|
61
|
+
title: doc_title,
|
|
62
|
+
children: children
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Extract semantic content from headers/footers
|
|
66
|
+
extract_header_footer_metadata(document, doc)
|
|
67
|
+
|
|
68
|
+
doc
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Walk body elements with list grouping look-ahead
|
|
74
|
+
def transform_elements(body, context)
|
|
75
|
+
return [] unless body
|
|
76
|
+
|
|
77
|
+
elements = body_ordered_elements(body)
|
|
78
|
+
|
|
79
|
+
result = []
|
|
80
|
+
i = 0
|
|
81
|
+
|
|
82
|
+
while i < elements.length
|
|
83
|
+
element = elements[i]
|
|
84
|
+
|
|
85
|
+
transformed = dispatch_element(element, i, elements, context)
|
|
86
|
+
|
|
87
|
+
case transformed
|
|
88
|
+
when Array
|
|
89
|
+
consumed = transformed.length
|
|
90
|
+
result.concat(transformed.compact)
|
|
91
|
+
i += consumed
|
|
92
|
+
when nil
|
|
93
|
+
i += 1
|
|
94
|
+
else
|
|
95
|
+
result << transformed
|
|
96
|
+
i += 1
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Dispatch a single body element, handling paragraphs specially
|
|
104
|
+
def dispatch_element(element, index, elements, context)
|
|
105
|
+
# Paragraphs need style-based dispatch (heading, list, or plain)
|
|
106
|
+
return dispatch_paragraph(element, index, elements, context) if paragraph?(element)
|
|
107
|
+
|
|
108
|
+
# Tables go through registry directly
|
|
109
|
+
context.transform(element)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def dispatch_paragraph(paragraph, index, elements, context)
|
|
113
|
+
resolver = context.style_resolver
|
|
114
|
+
|
|
115
|
+
# Check for section break in paragraph properties
|
|
116
|
+
if section_break?(paragraph)
|
|
117
|
+
# Section break without heading → thematic break
|
|
118
|
+
return section_break_element(paragraph, context)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Heading
|
|
122
|
+
return @heading_rule.apply(paragraph, context) if resolver.heading?(paragraph)
|
|
123
|
+
|
|
124
|
+
# List item — group consecutive items with same numId
|
|
125
|
+
return group_list(elements, index, context) if resolver.list_item?(paragraph)
|
|
126
|
+
|
|
127
|
+
# Regular paragraph (via registry)
|
|
128
|
+
context.transform(paragraph)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def section_break?(paragraph)
|
|
132
|
+
return false unless paragraph.is_a?(Uniword::Wordprocessingml::Paragraph)
|
|
133
|
+
return false unless paragraph.properties
|
|
134
|
+
|
|
135
|
+
sect_pr = paragraph.properties.section_properties
|
|
136
|
+
return false unless sect_pr
|
|
137
|
+
|
|
138
|
+
sect_pr.type ? true : false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def section_break_element(paragraph, context)
|
|
142
|
+
# First, transform the paragraph content if it has text
|
|
143
|
+
content = paragraph.runs&.map { |r| r.text&.content.to_s }&.join
|
|
144
|
+
if content && !content.strip.empty?
|
|
145
|
+
# Has content — transform normally (content comes before the break)
|
|
146
|
+
context.transform(paragraph)
|
|
147
|
+
else
|
|
148
|
+
# Standalone section break → thematic break
|
|
149
|
+
Coradoc::CoreModel::Block.new(
|
|
150
|
+
element_type: 'thematic_break'
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Collect consecutive list items with the same numId into a ListBlock
|
|
156
|
+
def group_list(elements, start_index, context)
|
|
157
|
+
first = elements[start_index]
|
|
158
|
+
num_id = first.properties&.num_id.to_i
|
|
159
|
+
items = []
|
|
160
|
+
consumed = 0
|
|
161
|
+
|
|
162
|
+
idx = start_index
|
|
163
|
+
while idx < elements.length
|
|
164
|
+
para = elements[idx]
|
|
165
|
+
break unless paragraph?(para)
|
|
166
|
+
break unless context.style_resolver.list_item?(para)
|
|
167
|
+
break unless para.properties&.num_id.to_i == num_id
|
|
168
|
+
|
|
169
|
+
items << @list_item_rule.apply(para, context)
|
|
170
|
+
consumed += 1
|
|
171
|
+
idx += 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
list_block = Coradoc::CoreModel::ListBlock.new(
|
|
175
|
+
marker_type: context.numbering_resolver.marker_type(num_id),
|
|
176
|
+
items: items
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Return array so caller knows how many elements were consumed
|
|
180
|
+
consumed > 1 ? [list_block] + Array.new(consumed - 1, nil) : list_block
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def body_ordered_elements(body)
|
|
184
|
+
order = body.is_a?(Uniword::Wordprocessingml::Body) ? body.element_order : nil
|
|
185
|
+
return body.elements if order.nil? || order.empty?
|
|
186
|
+
|
|
187
|
+
p_idx = tbl_idx = sdt_idx = 0
|
|
188
|
+
order.filter_map do |entry|
|
|
189
|
+
name = entry.is_a?(String) ? entry : entry.name
|
|
190
|
+
case name
|
|
191
|
+
when 'p'
|
|
192
|
+
para = body.paragraphs[p_idx]
|
|
193
|
+
p_idx += 1
|
|
194
|
+
para
|
|
195
|
+
when 'tbl'
|
|
196
|
+
tbl = body.tables[tbl_idx]
|
|
197
|
+
tbl_idx += 1
|
|
198
|
+
tbl
|
|
199
|
+
when 'sdt'
|
|
200
|
+
sdt = body.structured_document_tags&.[](sdt_idx)
|
|
201
|
+
sdt_idx += 1
|
|
202
|
+
sdt
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def paragraph?(element)
|
|
208
|
+
defined?(Uniword::Wordprocessingml::Paragraph) &&
|
|
209
|
+
element.is_a?(Uniword::Wordprocessingml::Paragraph)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def collect_footnotes(document)
|
|
213
|
+
footnotes = {}
|
|
214
|
+
|
|
215
|
+
doc_footnotes = document.footnotes
|
|
216
|
+
if doc_footnotes.is_a?(Hash)
|
|
217
|
+
doc_footnotes.each do |id, fn|
|
|
218
|
+
paragraphs = fn.is_a?(Uniword::Wordprocessingml::Footnote) ? fn.paragraphs : fn[:content]
|
|
219
|
+
footnotes[id.to_s] = Array(paragraphs)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if defined?(Uniword::Wordprocessingml::Footnotes) &&
|
|
224
|
+
document.footnotes.is_a?(Uniword::Wordprocessingml::Footnotes)
|
|
225
|
+
document.footnotes.footnotes.each do |fn|
|
|
226
|
+
id = fn.id&.to_s
|
|
227
|
+
next unless id
|
|
228
|
+
|
|
229
|
+
footnotes[id] = Array(fn.paragraphs || [])
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
footnotes
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def extract_document_title(document, context)
|
|
237
|
+
body = document.body
|
|
238
|
+
return nil unless body
|
|
239
|
+
|
|
240
|
+
paragraphs = body.paragraphs || []
|
|
241
|
+
paragraphs.each do |para|
|
|
242
|
+
next unless context.style_resolver.heading?(para)
|
|
243
|
+
next unless context.style_resolver.heading_level(para) == 1
|
|
244
|
+
|
|
245
|
+
runs = para.runs || []
|
|
246
|
+
return runs.map { |r| r.text&.content.to_s }.join
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_registry
|
|
253
|
+
registry = RuleRegistry.new
|
|
254
|
+
|
|
255
|
+
# Only register rules that don't need context for dispatch
|
|
256
|
+
registry.register(Rules::ParagraphRule.new)
|
|
257
|
+
registry.register(Rules::RunRule.new)
|
|
258
|
+
registry.register(Rules::TextRule.new)
|
|
259
|
+
registry.register(Rules::BreakRule.new)
|
|
260
|
+
registry.register(Rules::HyperlinkRule.new)
|
|
261
|
+
registry.register(Rules::ImageRule.new)
|
|
262
|
+
registry.register(Rules::FootnoteRule.new)
|
|
263
|
+
registry.register(Rules::BookmarkRule.new)
|
|
264
|
+
registry.register(Rules::TableRule.new)
|
|
265
|
+
registry.register(Rules::MathRule.new)
|
|
266
|
+
registry.register(Rules::StructuredDocumentTagRule.new)
|
|
267
|
+
registry.register(Rules::SimpleFieldRule.new)
|
|
268
|
+
registry.register(Rules::ProofErrorRule.new)
|
|
269
|
+
|
|
270
|
+
registry
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Extract semantic text from headers and footers.
|
|
274
|
+
# Discards purely layout text ("Page X of Y", page numbers, dates).
|
|
275
|
+
# Preserves meaningful text (title, version, confidentiality notices).
|
|
276
|
+
def extract_header_footer_metadata(document, core_doc)
|
|
277
|
+
extract_from_parts(document, :headers, 'header', core_doc)
|
|
278
|
+
extract_from_parts(document, :footers, 'footer', core_doc)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def extract_from_parts(document, method, prefix, core_doc)
|
|
282
|
+
parts = case method
|
|
283
|
+
when :headers then document.headers
|
|
284
|
+
when :footers then document.footers
|
|
285
|
+
end
|
|
286
|
+
return unless parts
|
|
287
|
+
|
|
288
|
+
Array(parts).each_with_index do |part, idx|
|
|
289
|
+
text = extract_part_text(part)
|
|
290
|
+
next if text.nil? || text.strip.empty?
|
|
291
|
+
next if layout_only_text?(text)
|
|
292
|
+
|
|
293
|
+
part_type = if part.is_a?(Uniword::Wordprocessingml::Header) ||
|
|
294
|
+
part.is_a?(Uniword::Wordprocessingml::Footer)
|
|
295
|
+
part.type
|
|
296
|
+
else
|
|
297
|
+
idx
|
|
298
|
+
end
|
|
299
|
+
core_doc.set_metadata("docx.#{prefix}.#{part_type}", text.strip)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def extract_part_text(part)
|
|
304
|
+
paragraphs = part.paragraphs || []
|
|
305
|
+
return nil unless paragraphs
|
|
306
|
+
|
|
307
|
+
paragraphs.map do |para|
|
|
308
|
+
extract_paragraph_text_content(para)
|
|
309
|
+
end.compact.join(' ').strip
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def extract_paragraph_text_content(para)
|
|
313
|
+
runs = para.runs || []
|
|
314
|
+
return nil unless runs
|
|
315
|
+
|
|
316
|
+
runs.map { |r| r.text&.content.to_s }.join
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Check if header/footer text is purely layout content
|
|
320
|
+
# (page numbers, "Page X of Y", dates, etc.)
|
|
321
|
+
def layout_only_text?(text)
|
|
322
|
+
stripped = text.strip
|
|
323
|
+
return true if stripped.empty?
|
|
324
|
+
|
|
325
|
+
# Pure numbers (page numbers)
|
|
326
|
+
return true if stripped.match?(/\A\d+\z/)
|
|
327
|
+
|
|
328
|
+
# Common page number patterns
|
|
329
|
+
return true if stripped.match?(/\APage\s+\d+(\s+of\s+\d+)?\z/i)
|
|
330
|
+
|
|
331
|
+
# Pure date patterns
|
|
332
|
+
return true if stripped.match?(%r{\A\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\z})
|
|
333
|
+
return true if stripped.match?(%r{\A\d{4}[/-]\d{1,2}[/-]\d{1,2}\z})
|
|
334
|
+
|
|
335
|
+
false
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Docx
|
|
5
|
+
module Transform
|
|
6
|
+
autoload :Rule, 'coradoc/docx/transform/rule'
|
|
7
|
+
autoload :RuleRegistry, 'coradoc/docx/transform/rule_registry'
|
|
8
|
+
autoload :Context, 'coradoc/docx/transform/context'
|
|
9
|
+
autoload :ToCoreModel, 'coradoc/docx/transform/to_core_model'
|
|
10
|
+
autoload :FromCoreModel, 'coradoc/docx/transform/from_core_model'
|
|
11
|
+
autoload :StyleResolver, 'coradoc/docx/transform/style_resolver'
|
|
12
|
+
autoload :NumberingResolver, 'coradoc/docx/transform/numbering_resolver'
|
|
13
|
+
autoload :OrderedContent, 'coradoc/docx/transform/ordered_content'
|
|
14
|
+
|
|
15
|
+
# Element transform rules
|
|
16
|
+
module Rules
|
|
17
|
+
autoload :TextRule, 'coradoc/docx/transform/rules/text_rule'
|
|
18
|
+
autoload :BreakRule, 'coradoc/docx/transform/rules/break_rule'
|
|
19
|
+
autoload :RunRule, 'coradoc/docx/transform/rules/run_rule'
|
|
20
|
+
autoload :HyperlinkRule, 'coradoc/docx/transform/rules/hyperlink_rule'
|
|
21
|
+
autoload :ImageRule, 'coradoc/docx/transform/rules/image_rule'
|
|
22
|
+
autoload :FootnoteRule, 'coradoc/docx/transform/rules/footnote_rule'
|
|
23
|
+
autoload :HeadingRule, 'coradoc/docx/transform/rules/heading_rule'
|
|
24
|
+
autoload :ListItemRule, 'coradoc/docx/transform/rules/list_item_rule'
|
|
25
|
+
autoload :ParagraphRule, 'coradoc/docx/transform/rules/paragraph_rule'
|
|
26
|
+
autoload :TableRule, 'coradoc/docx/transform/rules/table_rule'
|
|
27
|
+
autoload :MathRule, 'coradoc/docx/transform/rules/math_rule'
|
|
28
|
+
autoload :BookmarkRule, 'coradoc/docx/transform/rules/bookmark_rule'
|
|
29
|
+
autoload :StructuredDocumentTagRule,
|
|
30
|
+
'coradoc/docx/transform/rules/structured_document_tag_rule'
|
|
31
|
+
autoload :SimpleFieldRule,
|
|
32
|
+
'coradoc/docx/transform/rules/simple_field_rule'
|
|
33
|
+
autoload :ProofErrorRule,
|
|
34
|
+
'coradoc/docx/transform/rules/proof_error_rule'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|