uniword 1.0.11 → 1.2.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/config/font_metadata.yml +56 -0
- data/lib/uniword/accessibility/rules/descriptive_headings_rule.rb +1 -1
- data/lib/uniword/assembly/toc.rb +4 -14
- data/lib/uniword/assembly/variable_substitutor.rb +3 -4
- data/lib/uniword/batch/stages/normalize_styles_stage.rb +4 -2
- data/lib/uniword/builder/base_builder.rb +25 -0
- data/lib/uniword/builder/chart_builder.rb +33 -14
- data/lib/uniword/builder/deterministic_id.rb +17 -0
- data/lib/uniword/builder/document_builder.rb +129 -34
- data/lib/uniword/builder/footnote_builder.rb +22 -23
- data/lib/uniword/builder/header_footer_builder.rb +10 -10
- data/lib/uniword/builder/image_builder.rb +24 -16
- data/lib/uniword/builder/list_builder.rb +2 -1
- data/lib/uniword/builder/paragraph_builder.rb +95 -20
- data/lib/uniword/builder/run_utils.rb +97 -0
- data/lib/uniword/builder/section_builder.rb +40 -67
- data/lib/uniword/builder/table_builder.rb +32 -13
- data/lib/uniword/builder/table_cell_builder.rb +19 -0
- data/lib/uniword/builder.rb +27 -19
- data/lib/uniword/cli/diff_cli.rb +0 -1
- data/lib/uniword/cli/generate_cli.rb +0 -1
- data/lib/uniword/cli/headers_cli.rb +0 -1
- data/lib/uniword/cli/images_cli.rb +0 -1
- data/lib/uniword/cli/main.rb +6 -7
- data/lib/uniword/cli/protect_cli.rb +0 -1
- data/lib/uniword/cli/resources_cli.rb +0 -1
- data/lib/uniword/cli/review_cli.rb +0 -1
- data/lib/uniword/cli/spellcheck_cli.rb +0 -1
- data/lib/uniword/cli/styleset_cli.rb +0 -1
- data/lib/uniword/cli/template_cli.rb +0 -1
- data/lib/uniword/cli/theme_cli.rb +0 -1
- data/lib/uniword/cli/toc_cli.rb +0 -1
- data/lib/uniword/cli/watermark_cli.rb +0 -1
- data/lib/uniword/cli.rb +6 -17
- data/lib/uniword/diff/document_differ.rb +23 -5
- data/lib/uniword/diff/package_differ.rb +0 -1
- data/lib/uniword/diff.rb +4 -0
- data/lib/uniword/document_factory.rb +16 -25
- data/lib/uniword/docx/document_statistics.rb +42 -54
- data/lib/uniword/docx/id_allocator.rb +143 -0
- data/lib/uniword/docx/package.rb +42 -6
- data/lib/uniword/docx/package_defaults.rb +15 -25
- data/lib/uniword/docx/package_serialization.rb +131 -127
- data/lib/uniword/docx/reconciler/body.rb +197 -0
- data/lib/uniword/docx/reconciler/fix_codes.rb +74 -0
- data/lib/uniword/docx/reconciler/helpers.rb +337 -0
- data/lib/uniword/docx/reconciler/notes.rb +301 -0
- data/lib/uniword/docx/reconciler/package_structure.rb +322 -0
- data/lib/uniword/docx/reconciler/parts.rb +484 -0
- data/lib/uniword/docx/reconciler/referential_integrity.rb +449 -0
- data/lib/uniword/docx/reconciler/tables.rb +162 -0
- data/lib/uniword/docx/reconciler/theme.rb +141 -0
- data/lib/uniword/docx/reconciler.rb +44 -1097
- data/lib/uniword/docx.rb +3 -0
- data/lib/uniword/drawingml/color_scheme.rb +38 -5
- data/lib/uniword/hyperlink.rb +27 -26
- data/lib/uniword/infrastructure/zip_packager.rb +41 -38
- data/lib/uniword/lazy_loader.rb +36 -62
- data/lib/uniword/math.rb +3 -86
- data/lib/uniword/mhtml/document.rb +1 -3
- data/lib/uniword/model_attribute_access.rb +27 -0
- data/lib/uniword/ooxml/namespaces.rb +62 -2
- data/lib/uniword/ooxml/relationships/package_relationships.rb +7 -0
- data/lib/uniword/presentationml/slide_id.rb +0 -1
- data/lib/uniword/presentationml/slide_master_id.rb +0 -1
- data/lib/uniword/properties/bold.rb +0 -2
- data/lib/uniword/properties/boolean_formatting.rb +0 -2
- data/lib/uniword/properties/borders.rb +1 -1
- data/lib/uniword/properties/italic.rb +0 -2
- data/lib/uniword/properties/numbering_properties.rb +1 -1
- data/lib/uniword/properties/style_reference.rb +13 -0
- data/lib/uniword/quality/rules/heading_hierarchy_rule.rb +1 -1
- data/lib/uniword/quality/rules/style_consistency_rule.rb +1 -1
- data/lib/uniword/review/review_manager.rb +0 -2
- data/lib/uniword/template/helpers/loop_helper.rb +2 -2
- data/lib/uniword/template/variable_resolver.rb +14 -1
- data/lib/uniword/theme/theme_xml_parser.rb +1 -1
- data/lib/uniword/toc/toc_generator.rb +3 -14
- data/lib/uniword/transformation/mhtml_element_renderer.rb +94 -37
- data/lib/uniword/transformation/mhtml_metadata_builder.rb +1 -1
- data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
- data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/visitor/text_extractor.rb +2 -1
- data/lib/uniword/watermark/manager.rb +2 -2
- data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
- data/lib/uniword/wordprocessingml/body.rb +51 -34
- data/lib/uniword/wordprocessingml/body_div.rb +17 -0
- data/lib/uniword/wordprocessingml/div_border.rb +23 -0
- data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
- data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
- data/lib/uniword/wordprocessingml/document_root.rb +4 -3
- data/lib/uniword/wordprocessingml/endnote.rb +1 -1
- data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/footer.rb +4 -0
- data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
- data/lib/uniword/wordprocessingml/footnote.rb +1 -1
- data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/header.rb +4 -0
- data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
- data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
- data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
- data/lib/uniword/wordprocessingml/level.rb +2 -0
- data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
- data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
- data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
- data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
- data/lib/uniword/wordprocessingml/num.rb +4 -0
- data/lib/uniword/wordprocessingml/numbering.rb +1 -22
- data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
- data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
- data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
- data/lib/uniword/wordprocessingml/run.rb +32 -36
- data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
- data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
- data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
- data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
- data/lib/uniword/wordprocessingml/settings.rb +19 -17
- data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
- data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
- data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
- data/lib/uniword/wordprocessingml/text.rb +8 -2
- data/lib/uniword/wordprocessingml/web_div.rb +41 -0
- data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
- data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
- data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
- data/lib/uniword/wordprocessingml/zoom.rb +2 -0
- data/lib/uniword/wordprocessingml.rb +23 -0
- data/lib/uniword.rb +27 -0
- metadata +46 -70
- data/lib/uniword/math/accent.rb +0 -25
- data/lib/uniword/math/accent_properties.rb +0 -25
- data/lib/uniword/math/argument_properties.rb +0 -22
- data/lib/uniword/math/bar.rb +0 -25
- data/lib/uniword/math/bar_properties.rb +0 -25
- data/lib/uniword/math/begin_char.rb +0 -22
- data/lib/uniword/math/border_box.rb +0 -25
- data/lib/uniword/math/border_box_properties.rb +0 -39
- data/lib/uniword/math/box.rb +0 -25
- data/lib/uniword/math/box_properties.rb +0 -33
- data/lib/uniword/math/char.rb +0 -22
- data/lib/uniword/math/control_properties.rb +0 -27
- data/lib/uniword/math/degree.rb +0 -26
- data/lib/uniword/math/delimiter.rb +0 -25
- data/lib/uniword/math/delimiter_properties.rb +0 -33
- data/lib/uniword/math/denominator.rb +0 -26
- data/lib/uniword/math/element.rb +0 -61
- data/lib/uniword/math/end_char.rb +0 -22
- data/lib/uniword/math/equation_array.rb +0 -25
- data/lib/uniword/math/equation_array_properties.rb +0 -33
- data/lib/uniword/math/fraction.rb +0 -27
- data/lib/uniword/math/fraction_properties.rb +0 -25
- data/lib/uniword/math/function.rb +0 -27
- data/lib/uniword/math/function_name.rb +0 -26
- data/lib/uniword/math/function_properties.rb +0 -23
- data/lib/uniword/math/group_char.rb +0 -25
- data/lib/uniword/math/group_char_properties.rb +0 -29
- data/lib/uniword/math/lim.rb +0 -26
- data/lib/uniword/math/lower_limit.rb +0 -27
- data/lib/uniword/math/lower_limit_properties.rb +0 -23
- data/lib/uniword/math/math_break.rb +0 -22
- data/lib/uniword/math/math_font.rb +0 -22
- data/lib/uniword/math/math_properties.rb +0 -53
- data/lib/uniword/math/math_run.rb +0 -42
- data/lib/uniword/math/math_run_properties.rb +0 -37
- data/lib/uniword/math/math_simple_int_val.rb +0 -24
- data/lib/uniword/math/math_simple_val.rb +0 -24
- data/lib/uniword/math/math_style.rb +0 -22
- data/lib/uniword/math/math_text.rb +0 -23
- data/lib/uniword/math/matrix.rb +0 -25
- data/lib/uniword/math/matrix_column.rb +0 -23
- data/lib/uniword/math/matrix_column_properties.rb +0 -24
- data/lib/uniword/math/matrix_columns.rb +0 -23
- data/lib/uniword/math/matrix_properties.rb +0 -37
- data/lib/uniword/math/matrix_row.rb +0 -23
- data/lib/uniword/math/nary.rb +0 -29
- data/lib/uniword/math/nary_properties.rb +0 -33
- data/lib/uniword/math/numerator.rb +0 -26
- data/lib/uniword/math/o_math.rb +0 -65
- data/lib/uniword/math/o_math_para.rb +0 -25
- data/lib/uniword/math/o_math_para_properties.rb +0 -22
- data/lib/uniword/math/phantom.rb +0 -25
- data/lib/uniword/math/phantom_properties.rb +0 -33
- data/lib/uniword/math/pre_sub_superscript.rb +0 -29
- data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
- data/lib/uniword/math/radical.rb +0 -27
- data/lib/uniword/math/radical_properties.rb +0 -25
- data/lib/uniword/math/separator_char.rb +0 -22
- data/lib/uniword/math/sub.rb +0 -26
- data/lib/uniword/math/sub_superscript.rb +0 -29
- data/lib/uniword/math/sub_superscript_properties.rb +0 -25
- data/lib/uniword/math/subscript.rb +0 -27
- data/lib/uniword/math/subscript_properties.rb +0 -23
- data/lib/uniword/math/sup.rb +0 -26
- data/lib/uniword/math/superscript.rb +0 -27
- data/lib/uniword/math/superscript_properties.rb +0 -23
- data/lib/uniword/math/upper_limit.rb +0 -27
- data/lib/uniword/math/upper_limit_properties.rb +0 -23
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class Reconciler
|
|
6
|
+
# Document body reconciliation.
|
|
7
|
+
#
|
|
8
|
+
# Handles section properties, headers/footers, and their
|
|
9
|
+
# mc:Ignorable and paragraph backfill.
|
|
10
|
+
module Body
|
|
11
|
+
def reconcile_section_properties
|
|
12
|
+
return unless package.document&.body
|
|
13
|
+
|
|
14
|
+
body = package.document.body
|
|
15
|
+
|
|
16
|
+
unless body.section_properties
|
|
17
|
+
body.section_properties = Wordprocessingml::SectionProperties.new(
|
|
18
|
+
page_size: Wordprocessingml::PageDefaults.default_page_size,
|
|
19
|
+
page_margins: Wordprocessingml::PageDefaults.default_page_margins,
|
|
20
|
+
columns: Wordprocessingml::PageDefaults.default_columns,
|
|
21
|
+
doc_grid: Wordprocessingml::PageDefaults.default_doc_grid,
|
|
22
|
+
)
|
|
23
|
+
record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
|
|
24
|
+
"Added default section properties with US Letter page size")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sect_pr = body.section_properties
|
|
29
|
+
fixed = false
|
|
30
|
+
unless sect_pr.page_size
|
|
31
|
+
sect_pr.page_size = Wordprocessingml::PageDefaults.default_page_size
|
|
32
|
+
fixed = true
|
|
33
|
+
end
|
|
34
|
+
unless sect_pr.page_margins
|
|
35
|
+
sect_pr.page_margins = Wordprocessingml::PageDefaults.default_page_margins
|
|
36
|
+
fixed = true
|
|
37
|
+
end
|
|
38
|
+
unless sect_pr.columns
|
|
39
|
+
sect_pr.columns = Wordprocessingml::PageDefaults.default_columns
|
|
40
|
+
fixed = true
|
|
41
|
+
end
|
|
42
|
+
if fixed
|
|
43
|
+
record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
|
|
44
|
+
"Filled missing pgSz/pgMar/cols in existing section properties")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reconcile_headers_footers
|
|
49
|
+
ignorable = Ooxml::Types::McIgnorable.new(FULL_IGNORABLE)
|
|
50
|
+
set_header_footer_ignorable(package.document&.headers, ignorable)
|
|
51
|
+
set_header_footer_ignorable(package.document&.footers, ignorable)
|
|
52
|
+
|
|
53
|
+
unless allocator
|
|
54
|
+
rsid = generate_rsid
|
|
55
|
+
backfill_header_footer_parts(package.document&.headers, rsid, "hdr")
|
|
56
|
+
backfill_header_footer_parts(package.document&.footers, rsid, "ftr")
|
|
57
|
+
|
|
58
|
+
parts = package.document&.header_footer_parts
|
|
59
|
+
parts&.each_with_index do |part, pidx|
|
|
60
|
+
backfill_paragraphs(part[:content].paragraphs, rsid, "hfp:#{pidx}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
wire_builder_headers_footers
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def set_header_footer_ignorable(parts, ignorable)
|
|
70
|
+
return unless parts
|
|
71
|
+
|
|
72
|
+
parts.each_value { |part| part.mc_ignorable = ignorable }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def backfill_header_footer_parts(parts, rsid, prefix)
|
|
76
|
+
return unless parts
|
|
77
|
+
|
|
78
|
+
parts.each_with_index do |(_, part), pidx|
|
|
79
|
+
backfill_paragraphs(part.paragraphs, rsid, "#{prefix}:#{pidx}")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
HEADER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
|
|
84
|
+
FOOTER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
|
|
85
|
+
|
|
86
|
+
# Wire builder-path headers/footers into document_rels and sectPr
|
|
87
|
+
# during reconciliation so referential integrity checks see valid rIds.
|
|
88
|
+
def wire_builder_headers_footers
|
|
89
|
+
doc = package.document
|
|
90
|
+
return unless doc&.body
|
|
91
|
+
return unless doc.headers || doc.footers
|
|
92
|
+
|
|
93
|
+
rels = package.document_rels
|
|
94
|
+
return unless rels
|
|
95
|
+
|
|
96
|
+
counter = 0
|
|
97
|
+
wire_parts_to_rels(doc.headers, rels, HEADER_REL_TYPE,
|
|
98
|
+
"header", counter) do |type, r_id|
|
|
99
|
+
wire_sect_pr_reference(doc, :header, type, r_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
counter = 0
|
|
103
|
+
wire_parts_to_rels(doc.footers, rels, FOOTER_REL_TYPE,
|
|
104
|
+
"footer", counter) do |type, r_id|
|
|
105
|
+
wire_sect_pr_reference(doc, :footer, type, r_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Clear element_order so header/footer references serialize correctly.
|
|
109
|
+
# Ordered mode only outputs elements in element_order; the template's
|
|
110
|
+
# order may not include newly-added references.
|
|
111
|
+
sect_pr = doc.body.section_properties
|
|
112
|
+
sect_pr.element_order = nil if sect_pr&.element_order
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def wire_parts_to_rels(parts, rels, rel_type, file_prefix, counter)
|
|
116
|
+
return unless parts && !parts.empty?
|
|
117
|
+
|
|
118
|
+
parts.each_key do |type|
|
|
119
|
+
counter += 1
|
|
120
|
+
target = "#{file_prefix}#{counter}.xml"
|
|
121
|
+
r_id = if allocator
|
|
122
|
+
allocator.alloc_rid(target: target, type: rel_type)
|
|
123
|
+
else
|
|
124
|
+
find_or_create_rel(rels, rel_type, target)
|
|
125
|
+
end
|
|
126
|
+
yield type, r_id
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def find_or_create_rel(rels, rel_type, target)
|
|
131
|
+
existing = rels.relationships.find { |r| r.target == target }
|
|
132
|
+
return existing.id if existing
|
|
133
|
+
|
|
134
|
+
r_id = Ooxml::Relationships::PackageRelationships
|
|
135
|
+
.next_available_rid(rels)
|
|
136
|
+
rels.relationships << Ooxml::Relationships::Relationship.new(
|
|
137
|
+
id: r_id, type: rel_type, target: target,
|
|
138
|
+
)
|
|
139
|
+
r_id
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wire_sect_pr_reference(doc, kind, type, r_id)
|
|
143
|
+
sect_pr = doc.body.section_properties
|
|
144
|
+
return unless sect_pr
|
|
145
|
+
|
|
146
|
+
refs = case kind
|
|
147
|
+
when :header then sect_pr.header_references
|
|
148
|
+
when :footer then sect_pr.footer_references
|
|
149
|
+
end
|
|
150
|
+
return unless refs
|
|
151
|
+
|
|
152
|
+
existing = refs.find { |r| r.type == type }
|
|
153
|
+
if existing
|
|
154
|
+
existing.r_id = r_id
|
|
155
|
+
else
|
|
156
|
+
ref_class = if kind == :header
|
|
157
|
+
Wordprocessingml::HeaderReference
|
|
158
|
+
else
|
|
159
|
+
Wordprocessingml::FooterReference
|
|
160
|
+
end
|
|
161
|
+
refs << ref_class.new(type: type, r_id: r_id)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def reconcile_document_body
|
|
166
|
+
return unless profile
|
|
167
|
+
return unless package.document&.body
|
|
168
|
+
|
|
169
|
+
doc = package.document
|
|
170
|
+
set_mc_ignorable(doc, prefixes: FULL_IGNORABLE)
|
|
171
|
+
|
|
172
|
+
record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body")
|
|
173
|
+
|
|
174
|
+
body = doc.body
|
|
175
|
+
rsid = generate_rsid
|
|
176
|
+
|
|
177
|
+
body.paragraphs.each_with_index do |para, idx|
|
|
178
|
+
strip_empty_runs(para)
|
|
179
|
+
next if allocator
|
|
180
|
+
|
|
181
|
+
para.rsid_r ||= rsid
|
|
182
|
+
para.rsid_r_default ||= "00000000"
|
|
183
|
+
para.para_id ||= generate_hex_id(idx)
|
|
184
|
+
para.text_id ||= "77777777"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
record_fix(FixCodes::PARAGRAPH_BACKFILL, "Assigned rsid and paraId to paragraphs") unless allocator
|
|
188
|
+
|
|
189
|
+
sect_pr = body.section_properties
|
|
190
|
+
return unless sect_pr
|
|
191
|
+
|
|
192
|
+
sect_pr.rsid_r ||= rsid
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
class Reconciler
|
|
6
|
+
# Single source of truth for record_fix rule codes.
|
|
7
|
+
#
|
|
8
|
+
# Every `record_fix` call site references a constant from this module
|
|
9
|
+
# instead of a bare string literal. Adding a new fix category means
|
|
10
|
+
# adding one constant here, not hunting for the next free letter.
|
|
11
|
+
#
|
|
12
|
+
# Wire format is preserved — external validation rules and audit-log
|
|
13
|
+
# consumers that pattern-match on "R1".."R16" continue to work.
|
|
14
|
+
#
|
|
15
|
+
# Known debt: R10 is overloaded for several distinct concerns
|
|
16
|
+
# (note defs, table structure, style defaults, dangling refs).
|
|
17
|
+
# Splitting requires consumer coordination.
|
|
18
|
+
module FixCodes
|
|
19
|
+
# Settings / package structure
|
|
20
|
+
MC_IGNORABLE = "R1"
|
|
21
|
+
DOC_ID_GENERATED = "R2"
|
|
22
|
+
|
|
23
|
+
# Support parts
|
|
24
|
+
THEME_CREATED = "R3"
|
|
25
|
+
NUMBERING_REFERENCED = "R4"
|
|
26
|
+
|
|
27
|
+
# Package assembly
|
|
28
|
+
RELATIONSHIPS_ASSEMBLED = "R6"
|
|
29
|
+
CONTENT_TYPES_ASSEMBLED = "R7"
|
|
30
|
+
APP_PROPERTIES_ENSURED = "R8"
|
|
31
|
+
|
|
32
|
+
# Notes — pair creation
|
|
33
|
+
NOTE_PAIR_CREATED = "R9"
|
|
34
|
+
NOTE_DEFINITION_CREATED = "R10"
|
|
35
|
+
NOTE_INVALID_TYPE_STRIPPED = "R15"
|
|
36
|
+
NOTE_DUPLICATE_ID_REMOVED = "R16"
|
|
37
|
+
|
|
38
|
+
# Notes — referential integrity warnings and removals
|
|
39
|
+
DANGLING_NOTE_REFERENCE_WARNING = "R9"
|
|
40
|
+
DANGLING_NOTE_REFERENCE_REMOVED = "R10"
|
|
41
|
+
DANGLING_HYPERLINK_WARNING = "R9"
|
|
42
|
+
|
|
43
|
+
# Body
|
|
44
|
+
SECTION_PROPERTIES_DEFAULTED = "R11"
|
|
45
|
+
PARAGRAPH_BACKFILL = "R12"
|
|
46
|
+
|
|
47
|
+
# Parts
|
|
48
|
+
FONT_TABLE_CREATED = "R13"
|
|
49
|
+
CORE_PROPERTIES_REBUILT = "R14"
|
|
50
|
+
|
|
51
|
+
# Styles (currently shares R10 with other concerns — see debt note above)
|
|
52
|
+
STYLE_DEFAULTS_ADDED = "R10"
|
|
53
|
+
SEMI_HIDDEN_ADDED = "R10"
|
|
54
|
+
|
|
55
|
+
# Tables (currently shares R10)
|
|
56
|
+
TABLE_STRUCTURE_RECONCILED = "R10"
|
|
57
|
+
TABLE_CELL_PR_REORDERED = "R10"
|
|
58
|
+
TABLE_CELL_DEFAULTS = "R12"
|
|
59
|
+
TABLE_ROW_GRID_AFTER = "R13"
|
|
60
|
+
|
|
61
|
+
# Referential integrity (currently shares R10)
|
|
62
|
+
DANGLING_STYLE_REFERENCE_REMOVED = "R10"
|
|
63
|
+
DANGLING_BASED_ON_REMOVED = "R10"
|
|
64
|
+
DANGLING_NUMBERING_REMOVED = "R4"
|
|
65
|
+
DANGLING_HYPERLINK_REMOVED = "R10"
|
|
66
|
+
DANGLING_HEADER_FOOTER_REMOVED = "R11"
|
|
67
|
+
DANGLING_DRAWING_REMOVED = "R12"
|
|
68
|
+
|
|
69
|
+
# Run cleanup (currently shares R10)
|
|
70
|
+
EMPTY_RUNS_STRIPPED = "R10"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "set"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Uniword
|
|
8
|
+
module Docx
|
|
9
|
+
class Reconciler
|
|
10
|
+
# Shared helpers used across all reconciliation modules.
|
|
11
|
+
#
|
|
12
|
+
# Provides ID generation, document traversal, element_order
|
|
13
|
+
# manipulation, run utilities, and YAML config loading.
|
|
14
|
+
module Helpers
|
|
15
|
+
# -- ID generation --
|
|
16
|
+
|
|
17
|
+
def generate_rsid
|
|
18
|
+
"00#{hex_derive("rsid", 3)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def generate_hex_id(seed = 0)
|
|
22
|
+
hex_derive("paraId:#{seed}", 4)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def hex_derive(seed, byte_count)
|
|
26
|
+
Digest::SHA256.hexdigest(
|
|
27
|
+
"#{document_fingerprint}:#{seed}"
|
|
28
|
+
)[0...(byte_count * 2)].upcase
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def document_fingerprint
|
|
32
|
+
@document_fingerprint ||= begin
|
|
33
|
+
body = package.document&.body
|
|
34
|
+
return "empty" unless body
|
|
35
|
+
|
|
36
|
+
texts = []
|
|
37
|
+
walk_body_paragraphs(body) do |para|
|
|
38
|
+
texts << (para.runs || []).map(&:text_string).join
|
|
39
|
+
end
|
|
40
|
+
Digest::SHA256.hexdigest(texts.join("|"))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# -- Traversal --
|
|
45
|
+
|
|
46
|
+
def walk_body_paragraphs(body)
|
|
47
|
+
if body.element_order && !body.element_order.empty?
|
|
48
|
+
p_idx = 0
|
|
49
|
+
tbl_idx = 0
|
|
50
|
+
body.element_order.each do |entry|
|
|
51
|
+
case entry.name
|
|
52
|
+
when "p"
|
|
53
|
+
yield body.paragraphs[p_idx] if body.paragraphs[p_idx]
|
|
54
|
+
p_idx += 1
|
|
55
|
+
when "tbl"
|
|
56
|
+
if body.tables[tbl_idx]
|
|
57
|
+
walk_table_paragraphs(body.tables[tbl_idx]) { |p| yield p }
|
|
58
|
+
end
|
|
59
|
+
tbl_idx += 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
body.paragraphs.each { |p| yield p }
|
|
64
|
+
body.tables&.each { |tbl| walk_table_paragraphs(tbl) { |p| yield p } }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def walk_body_tables(body)
|
|
69
|
+
return unless body
|
|
70
|
+
|
|
71
|
+
if body.element_order && !body.element_order.empty?
|
|
72
|
+
tbl_idx = 0
|
|
73
|
+
body.element_order.each do |entry|
|
|
74
|
+
next unless entry.name == "tbl"
|
|
75
|
+
|
|
76
|
+
yield body.tables[tbl_idx] if body.tables[tbl_idx]
|
|
77
|
+
tbl_idx += 1
|
|
78
|
+
end
|
|
79
|
+
else
|
|
80
|
+
body.tables&.each { |tbl| yield tbl }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def walk_all_paragraphs(&block)
|
|
85
|
+
walk_body_paragraphs(package.document.body, &block)
|
|
86
|
+
|
|
87
|
+
(package.document&.header_footer_parts || []).each do |part|
|
|
88
|
+
(part[:content]&.paragraphs || []).each(&block)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def walk_table_paragraphs(table)
|
|
93
|
+
return unless table
|
|
94
|
+
table.rows&.each do |row|
|
|
95
|
+
row.cells&.each do |cell|
|
|
96
|
+
cell.paragraphs.each { |p| yield p }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# -- Element order --
|
|
102
|
+
|
|
103
|
+
def ensure_element_in_order(model, tag_name, after: nil, before: nil)
|
|
104
|
+
order = model.element_order
|
|
105
|
+
return unless order
|
|
106
|
+
|
|
107
|
+
return if order.any? { |e| e.name == tag_name }
|
|
108
|
+
|
|
109
|
+
entry = Lutaml::Xml::Element.new("Element", tag_name)
|
|
110
|
+
|
|
111
|
+
if after
|
|
112
|
+
idx = order.index { |e| e.name == after }
|
|
113
|
+
thaw_and_insert(model, order, idx ? idx + 1 : order.size, entry)
|
|
114
|
+
elsif before
|
|
115
|
+
idx = order.index { |e| e.name == before }
|
|
116
|
+
thaw_and_insert(model, order, idx || 0, entry)
|
|
117
|
+
else
|
|
118
|
+
thaw_and_append(model, order, entry)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def insert_element_order(obj, name, position)
|
|
123
|
+
order = obj.element_order
|
|
124
|
+
return unless order
|
|
125
|
+
|
|
126
|
+
return if order.any? { |e| e.name == name }
|
|
127
|
+
|
|
128
|
+
entry = Lutaml::Xml::Element.new("Element", name, node_type: :element)
|
|
129
|
+
thaw_and_insert(obj, order, position, entry)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# lutaml-model freezes element_order after XML parsing.
|
|
133
|
+
# Replace the frozen array with a mutable copy when modification is needed.
|
|
134
|
+
def thaw_and_insert(model, order, position, entry)
|
|
135
|
+
model.element_order = order.dup.insert(position, entry)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def thaw_and_append(model, order, entry)
|
|
139
|
+
model.element_order = order.dup << entry
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# -- Run utilities (delegate to RunUtils) --
|
|
143
|
+
|
|
144
|
+
def empty_run?(run)
|
|
145
|
+
Builder::RunUtils.empty_run?(run)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def strip_empty_runs(paragraph)
|
|
149
|
+
before = paragraph.runs.size
|
|
150
|
+
paragraph.runs.reject! { |r| empty_run?(r) }
|
|
151
|
+
removed = before - paragraph.runs.size
|
|
152
|
+
return unless removed.positive?
|
|
153
|
+
|
|
154
|
+
record_fix(FixCodes::EMPTY_RUNS_STRIPPED,
|
|
155
|
+
"Stripped #{removed} empty run(s) from " \
|
|
156
|
+
"#{paragraph.class.name.split('::').last}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def strip_empty_runs_from_notes(entries)
|
|
160
|
+
entries.each do |entry|
|
|
161
|
+
entry.paragraphs.each { |p| strip_empty_runs(p) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Shared paragraph backfill: assign rsid, paraId, textId defaults.
|
|
166
|
+
def backfill_paragraphs(paragraphs, rsid, id_seed)
|
|
167
|
+
paragraphs.each_with_index do |para, idx|
|
|
168
|
+
strip_empty_runs(para)
|
|
169
|
+
para.rsid_r ||= rsid
|
|
170
|
+
para.rsid_r_default ||= "00000000"
|
|
171
|
+
para.para_id ||= generate_hex_id("#{id_seed}:#{idx}")
|
|
172
|
+
para.text_id ||= "77777777"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Merge adjacent runs with identical formatting and consolidate
|
|
177
|
+
# standalone tab/br runs into following text runs (F4 + F5).
|
|
178
|
+
# Respects element_order: only merges runs that are truly adjacent
|
|
179
|
+
# in document order (not separated by hyperlinks, bookmarks, etc.).
|
|
180
|
+
def consolidate_runs(paragraph)
|
|
181
|
+
runs = paragraph.runs
|
|
182
|
+
return if runs.nil? || runs.size < 2
|
|
183
|
+
|
|
184
|
+
run_indices = adjacent_run_indices(paragraph)
|
|
185
|
+
merged = [runs.first]
|
|
186
|
+
runs[1..].each_with_index do |run, idx|
|
|
187
|
+
prev = merged.last
|
|
188
|
+
if run_indices.include?(idx) &&
|
|
189
|
+
run_properties_match?(prev, run) && can_merge?(prev, run)
|
|
190
|
+
merge_run_into(prev, run)
|
|
191
|
+
else
|
|
192
|
+
merged << run
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
paragraph.runs = merged
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Returns the set of run indices (0-based into runs[1..]) where
|
|
200
|
+
# the run at that index is immediately preceded by another run
|
|
201
|
+
# in element_order (no non-run elements between them).
|
|
202
|
+
def adjacent_run_indices(paragraph)
|
|
203
|
+
eo = paragraph.element_order
|
|
204
|
+
return (0...paragraph.runs.size - 1).to_a unless eo && !eo.empty?
|
|
205
|
+
|
|
206
|
+
indices = Set.new
|
|
207
|
+
run_count = 0
|
|
208
|
+
prev_was_run = false
|
|
209
|
+
eo.each do |entry|
|
|
210
|
+
if entry.name == "r"
|
|
211
|
+
if prev_was_run
|
|
212
|
+
indices << (run_count - 1)
|
|
213
|
+
end
|
|
214
|
+
run_count += 1
|
|
215
|
+
prev_was_run = true
|
|
216
|
+
else
|
|
217
|
+
prev_was_run = false
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
indices
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def consolidate_runs_in_body(body)
|
|
224
|
+
walk_body_paragraphs(body) { |p| consolidate_runs(p) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# -- YAML config --
|
|
228
|
+
|
|
229
|
+
CONFIG_DIR = File.join(__dir__, "../../../../config")
|
|
230
|
+
|
|
231
|
+
def load_font_metadata
|
|
232
|
+
path = File.join(CONFIG_DIR, "font_metadata.yml")
|
|
233
|
+
YAML.load_file(path)["fonts"]
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
Uniword.logger&.warn { "Font metadata load failed: #{e.message}" }
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def load_latent_styles_config
|
|
240
|
+
path = File.join(CONFIG_DIR, "latent_styles.yml")
|
|
241
|
+
YAML.load_file(path)
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
Uniword.logger&.warn do
|
|
244
|
+
"Latent styles config load failed: #{e.message}"
|
|
245
|
+
end
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# -- mc:Ignorable helpers --
|
|
250
|
+
|
|
251
|
+
def set_mc_ignorable(model, prefixes: EXTENSION_PREFIXES)
|
|
252
|
+
model.mc_ignorable = Ooxml::Types::McIgnorable.new(prefixes)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# -- Relationship builders --
|
|
256
|
+
|
|
257
|
+
def build_rel(id, type, target, target_mode: nil)
|
|
258
|
+
attrs = { id: id, type: type, target: target }
|
|
259
|
+
attrs[:target_mode] = target_mode if target_mode
|
|
260
|
+
Ooxml::Relationships::Relationship.new(**attrs)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def run_properties_match?(a, b)
|
|
264
|
+
Builder::RunUtils.properties_match?(a, b)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def can_merge?(prev, current)
|
|
268
|
+
Builder::RunUtils.text_only_run?(prev) &&
|
|
269
|
+
Builder::RunUtils.text_only_run?(current)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def text_only_run?(run)
|
|
273
|
+
Builder::RunUtils.text_only_run?(run)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def merge_run_into(target, source)
|
|
277
|
+
Builder::RunUtils.merge_text(target, source)
|
|
278
|
+
|
|
279
|
+
target.tab ||= source.tab
|
|
280
|
+
target.break ||= source.break
|
|
281
|
+
target.position_tab ||= source.position_tab
|
|
282
|
+
target.no_break_hyphen ||= source.no_break_hyphen
|
|
283
|
+
target.del_text ||= source.del_text
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# -- Note-type dispatch --
|
|
287
|
+
#
|
|
288
|
+
# Single source of truth for :footnote/:endnote discrimination.
|
|
289
|
+
# Adding a third note type means editing only these helpers.
|
|
290
|
+
|
|
291
|
+
def notes_collection_for(type)
|
|
292
|
+
case type
|
|
293
|
+
when :footnote then package.footnotes
|
|
294
|
+
when :endnote then package.endnotes
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def note_entries_for(notes, type)
|
|
299
|
+
return [] unless notes
|
|
300
|
+
|
|
301
|
+
case type
|
|
302
|
+
when :footnote then notes.footnote_entries
|
|
303
|
+
when :endnote then notes.endnote_entries
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def note_reference_from_run(run, type)
|
|
308
|
+
case type
|
|
309
|
+
when :footnote then run.footnote_reference
|
|
310
|
+
when :endnote then run.endnote_reference
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def build_notes_collection(type, entries:)
|
|
315
|
+
case type
|
|
316
|
+
when :footnote then Wordprocessingml::Footnotes.new(footnote_entries: entries)
|
|
317
|
+
when :endnote then Wordprocessingml::Endnotes.new(endnote_entries: entries)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def assign_note_pr(settings, type)
|
|
322
|
+
case type
|
|
323
|
+
when :footnote then settings.footnote_pr = Wordprocessingml::FootnotePr.new
|
|
324
|
+
when :endnote then settings.endnote_pr = Wordprocessingml::EndnotePr.new
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def set_notes_collection(notes, type)
|
|
329
|
+
case type
|
|
330
|
+
when :footnote then package.footnotes = notes
|
|
331
|
+
when :endnote then package.endnotes = notes
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|