uniword 1.2.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +313 -0
- data/CONTRIBUTING.md +1 -1
- data/config/ooxml/schemas/shared_types.yml +5 -4
- data/data/schemas/ecma/dc.xsd +118 -0
- data/data/schemas/ecma/dcmitype.xsd +50 -0
- data/data/schemas/ecma/dcterms.xsd +331 -0
- data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
- data/data/schemas/ecma/xml.xsd +117 -0
- data/lib/uniword/builder/bibliography_builder.rb +1 -1
- data/lib/uniword/builder/chart_builder.rb +22 -14
- data/lib/uniword/builder/comment_anchorer.rb +221 -0
- data/lib/uniword/builder/document_builder.rb +97 -6
- data/lib/uniword/builder/image_builder.rb +11 -10
- data/lib/uniword/builder/run_utils.rb +2 -0
- data/lib/uniword/builder.rb +1 -0
- data/lib/uniword/cli/fonts_cli.rb +52 -0
- data/lib/uniword/cli/main.rb +110 -11
- data/lib/uniword/cli/page_cli.rb +85 -0
- data/lib/uniword/cli/styles_cli.rb +204 -0
- data/lib/uniword/cli/theme_cli.rb +88 -0
- data/lib/uniword/cli/toc_cli.rb +22 -2
- data/lib/uniword/comment.rb +20 -15
- data/lib/uniword/comment_range.rb +3 -3
- data/lib/uniword/comments_part.rb +51 -19
- data/lib/uniword/configuration/configuration_loader.rb +5 -3
- data/lib/uniword/configuration.rb +93 -1
- data/lib/uniword/content_types.rb +19 -39
- data/lib/uniword/document_factory.rb +12 -18
- data/lib/uniword/document_writer.rb +16 -5
- data/lib/uniword/docx/chart_part.rb +34 -0
- data/lib/uniword/docx/custom_xml_item.rb +91 -0
- data/lib/uniword/docx/header_footer_part.rb +140 -0
- data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
- data/lib/uniword/docx/header_footer_view.rb +225 -0
- data/lib/uniword/docx/id_allocator.rb +130 -36
- data/lib/uniword/docx/image_part.rb +83 -0
- data/lib/uniword/docx/package.rb +141 -333
- data/lib/uniword/docx/package_defaults.rb +83 -99
- data/lib/uniword/docx/package_integrity_checker.rb +312 -0
- data/lib/uniword/docx/package_serialization.rb +144 -233
- data/lib/uniword/docx/part.rb +125 -0
- data/lib/uniword/docx/part_collection.rb +131 -0
- data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
- data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
- data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
- data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
- data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
- data/lib/uniword/docx/part_loader/load_context.rb +85 -0
- data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
- data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
- data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
- data/lib/uniword/docx/part_loader.rb +92 -0
- data/lib/uniword/docx/raw_part.rb +75 -0
- data/lib/uniword/docx/reconciler/body.rb +59 -68
- data/lib/uniword/docx/reconciler/fix.rb +46 -0
- data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
- data/lib/uniword/docx/reconciler/helpers.rb +18 -31
- data/lib/uniword/docx/reconciler/notes.rb +17 -8
- data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
- data/lib/uniword/docx/reconciler/parts.rb +36 -13
- data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -123
- data/lib/uniword/docx/reconciler/tables.rb +78 -16
- data/lib/uniword/docx/reconciler/theme.rb +7 -4
- data/lib/uniword/docx/reconciler.rb +36 -14
- data/lib/uniword/docx.rb +13 -0
- data/lib/uniword/drawingml/blip.rb +0 -1
- data/lib/uniword/drawingml/color_scheme.rb +5 -6
- data/lib/uniword/drawingml/font_scheme.rb +4 -8
- data/lib/uniword/drawingml/theme.rb +12 -6
- data/lib/uniword/errors.rb +8 -1
- data/lib/uniword/footer.rb +4 -0
- data/lib/uniword/header.rb +4 -0
- data/lib/uniword/hyperlink.rb +1 -1
- data/lib/uniword/image.rb +6 -6
- data/lib/uniword/images/image_manager.rb +13 -13
- data/lib/uniword/lazy_loader.rb +1 -2
- data/lib/uniword/model_attribute_access.rb +20 -4
- data/lib/uniword/ooxml/dotx_package.rb +42 -2
- data/lib/uniword/ooxml/element_order.rb +94 -0
- data/lib/uniword/ooxml/part_definition.rb +329 -0
- data/lib/uniword/ooxml/part_registry.rb +504 -0
- data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
- data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
- data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
- data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
- data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
- data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
- data/lib/uniword/ooxml/types.rb +10 -0
- data/lib/uniword/ooxml.rb +3 -0
- data/lib/uniword/properties/alignment.rb +8 -3
- data/lib/uniword/properties/border.rb +52 -6
- data/lib/uniword/properties/cell_vertical_align.rb +4 -1
- data/lib/uniword/properties/color_value.rb +8 -3
- data/lib/uniword/properties/highlight.rb +9 -5
- data/lib/uniword/properties/shading.rb +4 -3
- data/lib/uniword/properties/tab_stop.rb +6 -1
- data/lib/uniword/properties/table_justification.rb +4 -1
- data/lib/uniword/properties/underline.rb +4 -2
- data/lib/uniword/properties/vertical_align.rb +6 -3
- data/lib/uniword/review/review_manager.rb +23 -9
- data/lib/uniword/revision.rb +9 -9
- data/lib/uniword/schema/model_generator.rb +31 -15
- data/lib/uniword/shared_types/hex_color.rb +4 -1
- data/lib/uniword/shared_types/pixel_measure.rb +4 -1
- data/lib/uniword/shared_types/point_measure.rb +4 -1
- data/lib/uniword/shared_types/text_alignment.rb +6 -1
- data/lib/uniword/shared_types/twips_measure.rb +4 -1
- data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
- data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
- data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
- data/lib/uniword/template/variable_resolver.rb +40 -7
- data/lib/uniword/themes/theme_transformation.rb +55 -41
- data/lib/uniword/toc/toc_generator.rb +6 -5
- data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
- data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
- data/lib/uniword/validation/engine.rb +35 -0
- data/lib/uniword/validation/link_checker.rb +1 -1
- data/lib/uniword/validation/opc_validator.rb +16 -2
- data/lib/uniword/validation/report/layer_result.rb +0 -1
- data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
- data/lib/uniword/validation/report/verification_report.rb +0 -2
- data/lib/uniword/validation/report.rb +20 -0
- data/lib/uniword/validation/rules/base.rb +10 -2
- data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
- data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
- data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
- data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
- data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
- data/lib/uniword/validation/rules/document_context.rb +7 -0
- data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
- data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
- data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
- data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
- data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
- data/lib/uniword/validation/rules/images_rule.rb +0 -2
- data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/model_context.rb +32 -0
- data/lib/uniword/validation/rules/model_rule.rb +34 -0
- data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
- data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
- data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
- data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
- data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
- data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
- data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
- data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
- data/lib/uniword/validation/rules/tables_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_rule.rb +0 -2
- data/lib/uniword/validation/rules.rb +61 -23
- data/lib/uniword/validation/schema_registry.rb +11 -0
- data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
- data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -2
- data/lib/uniword/validation/validators.rb +0 -9
- data/lib/uniword/validation/verify_orchestrator.rb +0 -7
- data/lib/uniword/validation.rb +7 -2
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/vml/imagedata.rb +0 -1
- data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
- data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
- data/lib/uniword/wordprocessingml/document_root.rb +121 -122
- data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
- data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
- data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
- data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
- data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
- data/lib/uniword/wordprocessingml/level.rb +6 -20
- data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
- data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
- data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
- data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
- data/lib/uniword/wordprocessingml/rsids.rb +0 -2
- data/lib/uniword/wordprocessingml/run.rb +3 -0
- data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
- data/lib/uniword/wordprocessingml/settings.rb +8 -34
- data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/style.rb +2 -1
- data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
- data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
- data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
- data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
- data/lib/uniword/wordprocessingml.rb +5 -2
- data/lib/uniword.rb +27 -2
- metadata +52 -19
- data/config/validation_rules.yml +0 -60
- data/config/warning_rules.yml +0 -57
- data/lib/uniword/validation/document_validator.rb +0 -272
- data/lib/uniword/validation/structural_validator.rb +0 -116
- data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
- data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
- data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
- data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
- data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
- data/lib/uniword/validators/element_validator.rb +0 -93
- data/lib/uniword/validators/paragraph_validator.rb +0 -116
- data/lib/uniword/validators/table_validator.rb +0 -134
- data/lib/uniword/validators.rb +0 -9
- data/lib/uniword/warnings/warning.rb +0 -130
- data/lib/uniword/warnings/warning_collector.rb +0 -234
- data/lib/uniword/warnings/warning_report.rb +0 -159
- data/lib/uniword/warnings.rb +0 -9
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Ordered collection of HeaderFooterPart objects — the single
|
|
6
|
+
# storage path for all header/footer parts of a document, whether
|
|
7
|
+
# loaded from an existing DOCX or added programmatically.
|
|
8
|
+
#
|
|
9
|
+
# The +headers+/+footers+ views (HeaderFooterView) and the
|
|
10
|
+
# serializer both read from this store, so a part exists exactly
|
|
11
|
+
# once: one part file, one relationship, one content-type
|
|
12
|
+
# override, one sectPr reference.
|
|
13
|
+
class HeaderFooterPartCollection
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@parts = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Iterate over all parts (both kinds) in insertion order.
|
|
21
|
+
def each(&block)
|
|
22
|
+
@parts.each(&block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Integer]
|
|
26
|
+
def size
|
|
27
|
+
@parts.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
alias length size
|
|
31
|
+
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def empty?
|
|
34
|
+
@parts.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<HeaderFooterPart>]
|
|
38
|
+
def to_a
|
|
39
|
+
@parts.dup
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Append a part.
|
|
43
|
+
#
|
|
44
|
+
# @param part [HeaderFooterPart]
|
|
45
|
+
# @return [HeaderFooterPart] the added part
|
|
46
|
+
def <<(part)
|
|
47
|
+
@parts << part
|
|
48
|
+
part
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
alias add <<
|
|
52
|
+
|
|
53
|
+
# Remove a part (by identity).
|
|
54
|
+
#
|
|
55
|
+
# @param part [HeaderFooterPart]
|
|
56
|
+
# @return [HeaderFooterPart, nil] the removed part
|
|
57
|
+
def delete(part)
|
|
58
|
+
@parts.delete(part)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param kind [Symbol] :header or :footer
|
|
62
|
+
# @return [Array<HeaderFooterPart>] parts of that kind, in order
|
|
63
|
+
def of_kind(kind)
|
|
64
|
+
@parts.select { |part| part.kind == kind }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param kind [Symbol] :header or :footer
|
|
68
|
+
# @param type [String, nil] sectPr reference type
|
|
69
|
+
# @return [HeaderFooterPart, nil]
|
|
70
|
+
def find_part(kind, type)
|
|
71
|
+
type = type&.to_s
|
|
72
|
+
@parts.find { |part| part.kind == kind && part.type == type }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# All non-nil relationship targets in the store.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
def targets
|
|
79
|
+
@parts.filter_map(&:target)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# First unused numbered target for a kind
|
|
83
|
+
# ("header3.xml" when header1/header2 exist).
|
|
84
|
+
#
|
|
85
|
+
# @param kind [Symbol] :header or :footer
|
|
86
|
+
# @return [String]
|
|
87
|
+
def next_target(kind)
|
|
88
|
+
numbers = of_kind(kind).filter_map do |part|
|
|
89
|
+
part.target&.[](/\A#{kind}(\d+)\.xml\z/, 1)&.to_i
|
|
90
|
+
end
|
|
91
|
+
"#{kind}#{(numbers.max || 0) + 1}.xml"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Replace the entire store (legacy +header_footer_parts=+
|
|
95
|
+
# assignment). Accepts HeaderFooterPart objects and legacy
|
|
96
|
+
# part hashes ({ r_id:, target:, rel_type:, content_type:,
|
|
97
|
+
# content: }).
|
|
98
|
+
#
|
|
99
|
+
# @param parts [Array<HeaderFooterPart, Hash>]
|
|
100
|
+
# @return [void]
|
|
101
|
+
def replace_all(parts)
|
|
102
|
+
@parts = Array(parts).map { |part| self.class.wrap(part) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Replace all parts of one kind, leaving the other untouched.
|
|
106
|
+
#
|
|
107
|
+
# @param kind [Symbol] :header or :footer
|
|
108
|
+
# @param parts [Array<HeaderFooterPart>]
|
|
109
|
+
# @return [void]
|
|
110
|
+
def replace_kind(kind, parts)
|
|
111
|
+
@parts.reject! { |part| part.kind == kind }
|
|
112
|
+
parts.each { |part| @parts << part }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Normalize one entry into a HeaderFooterPart. Legacy hashes
|
|
116
|
+
# describe loaded parts (they carry original rIds/targets).
|
|
117
|
+
#
|
|
118
|
+
# @param value [HeaderFooterPart, Hash]
|
|
119
|
+
# @return [HeaderFooterPart]
|
|
120
|
+
def self.wrap(value)
|
|
121
|
+
return value if value.is_a?(HeaderFooterPart)
|
|
122
|
+
|
|
123
|
+
HeaderFooterPart.new(
|
|
124
|
+
r_id: value[:r_id],
|
|
125
|
+
target: value[:target],
|
|
126
|
+
rel_type: value[:rel_type],
|
|
127
|
+
content_type: value[:content_type],
|
|
128
|
+
type: value[:type],
|
|
129
|
+
content: value[:content],
|
|
130
|
+
loaded: true,
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Kind-filtered view over a document's HeaderFooterPartCollection.
|
|
6
|
+
#
|
|
7
|
+
# +document.headers+ and +document.footers+ return these views, so
|
|
8
|
+
# the Builder, the headers/footers managers and the serializer all
|
|
9
|
+
# share the single underlying store.
|
|
10
|
+
#
|
|
11
|
+
# Two historic access shapes are preserved:
|
|
12
|
+
# - Hash-style (Builder): +view["default"] = header_model+,
|
|
13
|
+
# +view["default"]+, +each_value+, +values+, +keys+, +key?+,
|
|
14
|
+
# +delete+. Content access yields the Header/Footer models.
|
|
15
|
+
# - Array-style (managers): +view << header+, +each+, +map+,
|
|
16
|
+
# +find+, +reject+. Iteration yields HeaderFooterPart objects,
|
|
17
|
+
# which carry the sectPr +type+ and delegate +paragraphs+,
|
|
18
|
+
# +tables+ and +empty?+ to their content.
|
|
19
|
+
#
|
|
20
|
+
# Upsert semantics: assigning a type that already exists replaces
|
|
21
|
+
# that part's content in place (keeping its target and rId), so
|
|
22
|
+
# loaded and builder-created parts can never duplicate.
|
|
23
|
+
class HeaderFooterView
|
|
24
|
+
include Enumerable
|
|
25
|
+
|
|
26
|
+
# @return [Symbol] :header or :footer
|
|
27
|
+
attr_reader :kind
|
|
28
|
+
|
|
29
|
+
# @param store [HeaderFooterPartCollection] the unified store
|
|
30
|
+
# @param kind [Symbol] :header or :footer
|
|
31
|
+
def initialize(store, kind)
|
|
32
|
+
@store = store
|
|
33
|
+
@kind = kind
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Iterate over the parts of this kind (Array-style access).
|
|
37
|
+
def each(&block)
|
|
38
|
+
@store.of_kind(kind).each(&block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Hash-style read: content model for a sectPr type.
|
|
42
|
+
#
|
|
43
|
+
# @param type [String, Symbol] "default"/"first"/"even"
|
|
44
|
+
# @return [Wordprocessingml::Header, Wordprocessingml::Footer, nil]
|
|
45
|
+
def [](type)
|
|
46
|
+
@store.find_part(kind, type)&.content
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Hash-style write: upsert a content model by sectPr type.
|
|
50
|
+
# Replaces the existing part's content in place when the type
|
|
51
|
+
# exists; otherwise appends a new part with the next free
|
|
52
|
+
# numbered target.
|
|
53
|
+
#
|
|
54
|
+
# @param type [String, Symbol] sectPr reference type
|
|
55
|
+
# @param model [Object] Wordprocessingml::Header/Footer or
|
|
56
|
+
# legacy Uniword::Header/Footer
|
|
57
|
+
# @return [Object] the assigned model
|
|
58
|
+
def []=(type, model)
|
|
59
|
+
type = type.to_s
|
|
60
|
+
content = coerce_content(model)
|
|
61
|
+
part = @store.find_part(kind, type)
|
|
62
|
+
if part
|
|
63
|
+
part.content = content
|
|
64
|
+
part.loaded = false
|
|
65
|
+
else
|
|
66
|
+
@store << HeaderFooterPart.new(
|
|
67
|
+
kind: kind, type: type, content: content,
|
|
68
|
+
target: @store.next_target(kind)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
model
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Append a model (Array-style). Legacy Uniword::Header/Footer
|
|
75
|
+
# models supply their own +type+; other models are stored
|
|
76
|
+
# without a type.
|
|
77
|
+
#
|
|
78
|
+
# @param model [Object] content model or HeaderFooterPart
|
|
79
|
+
# @return [Object] the appended model
|
|
80
|
+
def <<(model)
|
|
81
|
+
if model.is_a?(HeaderFooterPart)
|
|
82
|
+
adopt_part(model)
|
|
83
|
+
else
|
|
84
|
+
append_model(model)
|
|
85
|
+
end
|
|
86
|
+
model
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Iterate over sectPr types.
|
|
90
|
+
def each_key(&block)
|
|
91
|
+
map(&:type).each(&block)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Iterate over content models.
|
|
95
|
+
def each_value(&block)
|
|
96
|
+
map(&:content).each(&block)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Array<String, nil>] sectPr types
|
|
100
|
+
def keys
|
|
101
|
+
map(&:type)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Array] content models
|
|
105
|
+
def values
|
|
106
|
+
map(&:content)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param type [String, Symbol]
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def key?(type)
|
|
112
|
+
!@store.find_part(kind, type).nil?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Remove the part for a sectPr type.
|
|
116
|
+
#
|
|
117
|
+
# @param type [String, Symbol]
|
|
118
|
+
# @return [Object, nil] the removed part's content
|
|
119
|
+
def delete(type)
|
|
120
|
+
part = @store.find_part(kind, type)
|
|
121
|
+
return nil unless part
|
|
122
|
+
|
|
123
|
+
@store.delete(part)
|
|
124
|
+
part.content
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Integer]
|
|
128
|
+
def size
|
|
129
|
+
@store.of_kind(kind).size
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def empty?
|
|
134
|
+
size.zero?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Bulk assignment (+document.headers = value+). Accepts nil
|
|
138
|
+
# (clears this kind), a Hash of type => model, an Array of
|
|
139
|
+
# models/parts, or the view itself (no-op).
|
|
140
|
+
#
|
|
141
|
+
# @param value [nil, Hash, Array, HeaderFooterView]
|
|
142
|
+
# @return [Object] the assigned value
|
|
143
|
+
def replace(value)
|
|
144
|
+
case value
|
|
145
|
+
when nil then @store.replace_kind(kind, [])
|
|
146
|
+
when HeaderFooterView then nil
|
|
147
|
+
when Hash then replace_from_hash(value)
|
|
148
|
+
when Array then replace_from_array(value)
|
|
149
|
+
else
|
|
150
|
+
raise ArgumentError,
|
|
151
|
+
"cannot assign #{value.class} to document.#{kind}s"
|
|
152
|
+
end
|
|
153
|
+
value
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def adopt_part(part)
|
|
159
|
+
part.target ||= @store.next_target(part.kind || kind)
|
|
160
|
+
@store << part
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def append_model(model)
|
|
164
|
+
type = legacy_type(model)
|
|
165
|
+
return self[type] = model if type
|
|
166
|
+
|
|
167
|
+
@store << HeaderFooterPart.new(
|
|
168
|
+
kind: kind, content: coerce_content(model),
|
|
169
|
+
target: @store.next_target(kind)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def replace_from_hash(hash)
|
|
174
|
+
hash.each { |type, model| self[type] = model }
|
|
175
|
+
kept = hash.keys.map(&:to_s)
|
|
176
|
+
@store.of_kind(kind).each do |part|
|
|
177
|
+
@store.delete(part) if stale_fresh_part?(part, kept)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A programmatically-added part is stale after a Hash assignment
|
|
182
|
+
# when its type is not among the assigned keys (loaded parts are
|
|
183
|
+
# never removed by view assignment).
|
|
184
|
+
def stale_fresh_part?(part, kept_types)
|
|
185
|
+
!part.loaded? && !part.type.nil? && !kept_types.include?(part.type)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def replace_from_array(array)
|
|
189
|
+
parts = array.map { |entry| coerce_to_part(entry) }
|
|
190
|
+
@store.replace_kind(kind, [])
|
|
191
|
+
parts.each do |part|
|
|
192
|
+
part.target ||= @store.next_target(kind)
|
|
193
|
+
@store << part
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def coerce_to_part(entry)
|
|
198
|
+
case entry
|
|
199
|
+
when HeaderFooterPart then entry
|
|
200
|
+
when Hash then HeaderFooterPartCollection.wrap(entry)
|
|
201
|
+
else
|
|
202
|
+
HeaderFooterPart.new(
|
|
203
|
+
kind: kind, type: legacy_type(entry),
|
|
204
|
+
content: coerce_content(entry)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Legacy Uniword::Header/Footer carry their sectPr type.
|
|
210
|
+
def legacy_type(model)
|
|
211
|
+
case model
|
|
212
|
+
when Uniword::Header, Uniword::Footer then model.type
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Content is stored as given: legacy Uniword::Header/Footer
|
|
217
|
+
# models stay live (callers may keep mutating them) and are
|
|
218
|
+
# converted to their Wordprocessingml counterparts only at
|
|
219
|
+
# serialization time (HeaderFooterPart#serializable_content).
|
|
220
|
+
def coerce_content(model)
|
|
221
|
+
model
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -17,22 +17,32 @@ module Uniword
|
|
|
17
17
|
#
|
|
18
18
|
# This is the "populate-first" principle: when loading a template DOCX,
|
|
19
19
|
# parse and seed ALL existing IDs from the template before modification.
|
|
20
|
+
#
|
|
21
|
+
# rId namespaces are per relationships part: "rId1" in _rels/.rels and
|
|
22
|
+
# "rId1" in word/_rels/document.xml.rels do not collide. The allocator
|
|
23
|
+
# therefore tracks rIds per +scope+ (:document for document-level rels,
|
|
24
|
+
# :package for package-level rels); each scope has its own registry,
|
|
25
|
+
# counter and uniqueness domain.
|
|
20
26
|
class IdAllocator
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
# Relationship type namespace base (also the r: namespace URI).
|
|
28
|
+
# Individual rel type constants derive from Ooxml::PartRegistry,
|
|
29
|
+
# the single source of truth for part metadata.
|
|
30
|
+
REL_TYPE_BASE = Ooxml::PartRegistry::OFFICE_REL_BASE
|
|
31
|
+
|
|
32
|
+
IMAGE_REL_TYPE = Ooxml::PartRegistry.find_by_key(:image).rel_type
|
|
33
|
+
HEADER_REL_TYPE = Ooxml::PartRegistry.find_by_key(:header).rel_type
|
|
34
|
+
FOOTER_REL_TYPE = Ooxml::PartRegistry.find_by_key(:footer).rel_type
|
|
35
|
+
HYPERLINK_REL_TYPE = Ooxml::PartRegistry.find_by_key(:hyperlink).rel_type
|
|
36
|
+
CHART_REL_TYPE = Ooxml::PartRegistry.find_by_key(:chart).rel_type
|
|
37
|
+
FOOTNOTES_REL_TYPE = Ooxml::PartRegistry.find_by_key(:footnotes).rel_type
|
|
38
|
+
ENDNOTES_REL_TYPE = Ooxml::PartRegistry.find_by_key(:endnotes).rel_type
|
|
39
|
+
THEME_REL_TYPE = Ooxml::PartRegistry.find_by_key(:theme).rel_type
|
|
40
|
+
NUMBERING_REL_TYPE = Ooxml::PartRegistry.find_by_key(:numbering).rel_type
|
|
32
41
|
|
|
33
42
|
def initialize
|
|
34
|
-
@
|
|
35
|
-
|
|
43
|
+
@rid_counters = Hash.new(0) # scope -> high-water mark
|
|
44
|
+
# [scope, target, type] -> { id, type, target, target_mode, scope }
|
|
45
|
+
@rid_entries = {}
|
|
36
46
|
@footnote_counter = 1
|
|
37
47
|
@endnote_counter = 1
|
|
38
48
|
@bookmark_counter = 0
|
|
@@ -43,16 +53,56 @@ module Uniword
|
|
|
43
53
|
|
|
44
54
|
# Allocate a relationship ID for a target+type pair.
|
|
45
55
|
# Returns existing rId if this target+type was already registered.
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
#
|
|
57
|
+
# @param target [String] relationship target
|
|
58
|
+
# @param type [String] relationship type
|
|
59
|
+
# @param target_mode [String, nil] e.g. "External"
|
|
60
|
+
# @param scope [Symbol] :document or :package (rels part namespace)
|
|
61
|
+
# @return [String] the allocated (or existing) rId
|
|
62
|
+
def alloc_rid(target:, type:, target_mode: nil, scope: :document)
|
|
63
|
+
key = [scope, target, type.to_s]
|
|
48
64
|
@rid_entries[key] ||= begin
|
|
49
|
-
@
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
@rid_counters[scope] += 1
|
|
66
|
+
@rid_counters[scope] += 1 while rid_id_taken?(
|
|
67
|
+
"rId#{@rid_counters[scope]}", scope
|
|
68
|
+
)
|
|
69
|
+
{ id: "rId#{@rid_counters[scope]}", type: type.to_s,
|
|
70
|
+
target: target, target_mode: target_mode, scope: scope }
|
|
52
71
|
end
|
|
53
72
|
@rid_entries[key][:id]
|
|
54
73
|
end
|
|
55
74
|
|
|
75
|
+
# Register an explicit rId for a target+type pair.
|
|
76
|
+
# Used when a relationship id is assigned outside the counter
|
|
77
|
+
# (rId dedup repair) so later lookups stay consistent.
|
|
78
|
+
#
|
|
79
|
+
# @param id [String] relationship ID to register
|
|
80
|
+
# @param target [String] relationship target
|
|
81
|
+
# @param type [String] relationship type
|
|
82
|
+
# @param target_mode [String, nil] e.g. "External"
|
|
83
|
+
# @param scope [Symbol] :document or :package
|
|
84
|
+
# @return [String] the registered id
|
|
85
|
+
def register_rid(id, target:, type:, target_mode: nil, scope: :document)
|
|
86
|
+
@rid_entries[[scope, target, type.to_s]] = {
|
|
87
|
+
id: id, type: type.to_s, target: target, target_mode: target_mode,
|
|
88
|
+
scope: scope
|
|
89
|
+
}
|
|
90
|
+
num = id[/\ArId(\d+)\z/, 1]&.to_i || 0
|
|
91
|
+
@rid_counters[scope] = [@rid_counters[scope], num].max
|
|
92
|
+
id
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The next free rId past the high-water mark of a scope —
|
|
96
|
+
# guaranteed not registered to any target+type in that scope.
|
|
97
|
+
#
|
|
98
|
+
# @param scope [Symbol] :document or :package
|
|
99
|
+
# @return [String] e.g. "rId7"
|
|
100
|
+
def next_free_rid(scope: :document)
|
|
101
|
+
candidate = @rid_counters[scope] + 1
|
|
102
|
+
candidate += 1 while rid_id_taken?("rId#{candidate}", scope)
|
|
103
|
+
"rId#{candidate}"
|
|
104
|
+
end
|
|
105
|
+
|
|
56
106
|
def alloc_footnote_id
|
|
57
107
|
id = @footnote_counter
|
|
58
108
|
@footnote_counter += 1
|
|
@@ -85,21 +135,19 @@ module Uniword
|
|
|
85
135
|
Digest::SHA256.hexdigest("rsid:#{@rsid_counter}").upcase[0, 8]
|
|
86
136
|
end
|
|
87
137
|
|
|
88
|
-
# Seed from a relationships collection — preserves existing rIds
|
|
89
|
-
|
|
138
|
+
# Seed from a relationships collection — preserves existing rIds
|
|
139
|
+
# verbatim so a load→save round-trip is rId-stable. A loaded rId
|
|
140
|
+
# already registered to a different target+type in the same scope
|
|
141
|
+
# (only possible when allocation preceded seeding, or the source
|
|
142
|
+
# has duplicate ids) yields a fresh counter allocation for the
|
|
143
|
+
# seeded pair instead — uniqueness wins over verbatim preservation.
|
|
144
|
+
#
|
|
145
|
+
# @param relationships [Array, nil] rels to seed
|
|
146
|
+
# @param scope [Symbol] :document or :package (rels part namespace)
|
|
147
|
+
def seed_from_rels(relationships, scope: :document)
|
|
90
148
|
return unless relationships
|
|
91
149
|
|
|
92
|
-
relationships.each
|
|
93
|
-
key = [r.target, r.type.to_s]
|
|
94
|
-
@rid_entries[key] = {
|
|
95
|
-
id: r.id,
|
|
96
|
-
type: r.type.to_s,
|
|
97
|
-
target: r.target,
|
|
98
|
-
target_mode: r.target_mode,
|
|
99
|
-
}
|
|
100
|
-
num = r.id[/\ArId(\d+)\z/, 1]&.to_i || 0
|
|
101
|
-
@rid_counter = [@rid_counter, num].max
|
|
102
|
-
end
|
|
150
|
+
relationships.each { |r| seed_rel(r, scope) }
|
|
103
151
|
end
|
|
104
152
|
|
|
105
153
|
# Seed footnote/endnote counters from existing note entries.
|
|
@@ -114,14 +162,26 @@ module Uniword
|
|
|
114
162
|
end
|
|
115
163
|
end
|
|
116
164
|
|
|
165
|
+
# Seed the comment counter from existing comment entries so newly
|
|
166
|
+
# allocated comment IDs do not collide with loaded ones.
|
|
167
|
+
def seed_from_comments(comments)
|
|
168
|
+
comments&.each do |c|
|
|
169
|
+
@comment_counter = [@comment_counter, c.comment_id.to_i].max
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
117
173
|
# Produce the final ordered list of all allocated relationships.
|
|
118
|
-
|
|
119
|
-
|
|
174
|
+
#
|
|
175
|
+
# @param scope [Symbol, nil] restrict to one scope; nil for all
|
|
176
|
+
def all_rels(scope: nil)
|
|
177
|
+
entries = @rid_entries.values
|
|
178
|
+
entries = entries.select { |r| r[:scope] == scope } if scope
|
|
179
|
+
entries.sort_by { |r| r[:id][/\d+/]&.to_i || 0 }
|
|
120
180
|
end
|
|
121
181
|
|
|
122
182
|
# Check if a relationship has been registered for a target+type.
|
|
123
|
-
def rid_for(target:, type:)
|
|
124
|
-
key = [target, type.to_s]
|
|
183
|
+
def rid_for(target:, type:, scope: :document)
|
|
184
|
+
key = [scope, target, type.to_s]
|
|
125
185
|
@rid_entries[key]&.fetch(:id, nil)
|
|
126
186
|
end
|
|
127
187
|
|
|
@@ -131,13 +191,47 @@ module Uniword
|
|
|
131
191
|
def self.populate_from_package(package)
|
|
132
192
|
alloc = new
|
|
133
193
|
alloc.seed_from_rels(package.document_rels&.relationships)
|
|
134
|
-
alloc.seed_from_rels(package.package_rels&.relationships
|
|
194
|
+
alloc.seed_from_rels(package.package_rels&.relationships,
|
|
195
|
+
scope: :package)
|
|
135
196
|
alloc.seed_from_notes(
|
|
136
197
|
package.footnotes&.footnote_entries,
|
|
137
198
|
package.endnotes&.endnote_entries,
|
|
138
199
|
)
|
|
200
|
+
alloc.seed_from_comments(comment_entries_of(package))
|
|
139
201
|
alloc
|
|
140
202
|
end
|
|
203
|
+
|
|
204
|
+
# Comment entries of a package or document, tolerating the legacy
|
|
205
|
+
# Array form of DocumentRoot#comments.
|
|
206
|
+
def self.comment_entries_of(source)
|
|
207
|
+
part = source.comments
|
|
208
|
+
part.is_a?(Uniword::CommentsPart) ? part.comments : part
|
|
209
|
+
end
|
|
210
|
+
private_class_method :comment_entries_of
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# Seed one relationship (see seed_from_rels for the collision rule).
|
|
215
|
+
def seed_rel(rel, scope)
|
|
216
|
+
key = [scope, rel.target, rel.type.to_s]
|
|
217
|
+
return if @rid_entries.key?(key)
|
|
218
|
+
|
|
219
|
+
if rid_id_taken?(rel.id, scope)
|
|
220
|
+
alloc_rid(target: rel.target, type: rel.type,
|
|
221
|
+
target_mode: rel.target_mode, scope: scope)
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
register_rid(rel.id, target: rel.target, type: rel.type,
|
|
226
|
+
target_mode: rel.target_mode, scope: scope)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Whether an rId string is already registered within a scope.
|
|
230
|
+
def rid_id_taken?(id, scope)
|
|
231
|
+
@rid_entries.any? do |key, entry|
|
|
232
|
+
key[0] == scope && entry[:id] == id
|
|
233
|
+
end
|
|
234
|
+
end
|
|
141
235
|
end
|
|
142
236
|
end
|
|
143
237
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# An image part (word/media/*) held by the package: binary image
|
|
6
|
+
# data plus the packaging metadata needed to emit it (relationship
|
|
7
|
+
# target, relationship id, content type).
|
|
8
|
+
#
|
|
9
|
+
# The image content type is per-file (resolved from the file
|
|
10
|
+
# extension), so it is always carried explicitly — the registry's
|
|
11
|
+
# :image definition contributes only the relationship type.
|
|
12
|
+
#
|
|
13
|
+
# Replaces the raw +{ data:, target:, content_type:, path: }+ hash
|
|
14
|
+
# entries formerly stored in +DocumentRoot#image_parts+. Hash-style
|
|
15
|
+
# read access (+part[:data]+, +part[:target]+, +part[:content_type]+,
|
|
16
|
+
# +part[:path]+) is kept for backward compatibility.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# part = ImagePart.new(
|
|
20
|
+
# r_id: "rId5", target: "media/image1.png",
|
|
21
|
+
# data: binary_data, content_type: "image/png",
|
|
22
|
+
# )
|
|
23
|
+
# part.data # => binary image data
|
|
24
|
+
# part.content_type # => "image/png"
|
|
25
|
+
class ImagePart < Part
|
|
26
|
+
# @return [String, nil] path of the source file the image was
|
|
27
|
+
# read from (builder-added images only; nil for loaded parts)
|
|
28
|
+
attr_accessor :source_path
|
|
29
|
+
|
|
30
|
+
# @param r_id [String, nil] relationship id
|
|
31
|
+
# @param target [String, nil] e.g. "media/image1.png"
|
|
32
|
+
# @param data [String, nil] binary image data
|
|
33
|
+
# @param content_type [String, nil] e.g. "image/png"
|
|
34
|
+
# @param source_path [String, nil] on-disk source path
|
|
35
|
+
# rubocop:disable Metrics/ParameterLists
|
|
36
|
+
def initialize(r_id: nil, target: nil, data: nil, content_type: nil,
|
|
37
|
+
source_path: nil, **rest)
|
|
38
|
+
super(
|
|
39
|
+
definition: Ooxml::PartRegistry.find_by_key(:image),
|
|
40
|
+
r_id: r_id, target: target, content: data,
|
|
41
|
+
content_type: content_type, **rest
|
|
42
|
+
)
|
|
43
|
+
@source_path = source_path
|
|
44
|
+
end
|
|
45
|
+
# rubocop:enable Metrics/ParameterLists
|
|
46
|
+
|
|
47
|
+
# Wrap a legacy raw-hash entry ({ data:, target:, content_type:,
|
|
48
|
+
# path: }) into an ImagePart.
|
|
49
|
+
#
|
|
50
|
+
# @param hash [Hash] legacy hash entry
|
|
51
|
+
# @return [ImagePart]
|
|
52
|
+
def self.from_hash(hash)
|
|
53
|
+
new(target: hash[:target], data: hash[:data],
|
|
54
|
+
content_type: hash[:content_type], source_path: hash[:path])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Binary image data (alias for content).
|
|
58
|
+
#
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
def data
|
|
61
|
+
content
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Set the binary image data (alias for content=).
|
|
65
|
+
#
|
|
66
|
+
# @param value [String, nil] binary image data
|
|
67
|
+
# @return [void]
|
|
68
|
+
def data=(value)
|
|
69
|
+
self.content = value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Hash-style read compatibility (+:data+ and the legacy +:path+
|
|
73
|
+
# source path in addition to the Part keys).
|
|
74
|
+
def [](key)
|
|
75
|
+
case key.to_sym
|
|
76
|
+
when :data then content
|
|
77
|
+
when :path then source_path
|
|
78
|
+
else super
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|