uniword 1.4.1 → 1.5.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 +50 -0
- data/lib/uniword/configuration.rb +55 -2
- data/lib/uniword/content_types/types.rb +27 -0
- data/lib/uniword/docx/junk_classifier.rb +101 -0
- data/lib/uniword/docx/package.rb +23 -0
- data/lib/uniword/docx/package_serialization.rb +7 -1
- data/lib/uniword/docx/part_loader/raw_part_loader.rb +95 -37
- data/lib/uniword/docx/stripped_part.rb +56 -0
- data/lib/uniword/docx.rb +2 -0
- data/lib/uniword/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6093ee22688fc357250708811cdb7035b17ff9a036ec5dd48f86381632b2c72
|
|
4
|
+
data.tar.gz: b76ab89a27b22a8a7ed5293f771cedaa165c3263a853f3322eb9f3d06cd0625a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dedfcb861076ff92eb99dda31c460e02a4c184fd9ce271abdfc63250dd100235f0889e4705899d2f4566eefa43b329094d77128f9ff9deeae3ff635ca4051218
|
|
7
|
+
data.tar.gz: 226c5d1acc52df7f5519935ab2817b73ac71382fb95132e66cd6bfc4f1d912dba604996d0e1eff9996791f0bf0a38df121d27d54e5a012c1236d582580f93484
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.5.0] - 2026-07-22
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `Configuration#on_noncompliant_content` policy knob (default
|
|
16
|
+
`:strip`, alternative `:raise`). Controls what happens when a
|
|
17
|
+
loaded package contains parts with no content type declaration.
|
|
18
|
+
- `Docx::JunkClassifier` — classifies a package-relative path as
|
|
19
|
+
junk based on OS/tooling artifact patterns and the OPC "no content
|
|
20
|
+
type AND no referencing relationship" rule. Single source of truth
|
|
21
|
+
for the junk decision.
|
|
22
|
+
- `Docx::StrippedPart` value object (path + reason) — the reporting
|
|
23
|
+
record for parts stripped at load.
|
|
24
|
+
- `ContentTypes::Types#content_type_for(path)` — Override-then-Default
|
|
25
|
+
lookup. Single source of truth for content type resolution,
|
|
26
|
+
replacing duplicated lookup logic.
|
|
27
|
+
- `Package#stripped_parts` — `Array<StrippedPart>` populated by the
|
|
28
|
+
loader in `:strip` mode. Empty in `:raise` mode and for
|
|
29
|
+
programmatically-constructed packages.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **Default behavior change**: `Docx::RawPartLoader` now strips
|
|
34
|
+
non-compliant parts at load by default (Word-identical behavior).
|
|
35
|
+
Previously, parts with no content type declaration were preserved
|
|
36
|
+
as `RawPart(content_type: nil)`, which then tripped OPC-005 at the
|
|
37
|
+
write-time integrity gate. Legitimate unmodelled parts (those with
|
|
38
|
+
a content type declaration, e.g. VBA, glossary, `docProps/meta.xml`)
|
|
39
|
+
continue to round-trip byte-for-byte per the 1.4.0 promise.
|
|
40
|
+
- Stripped parts are recorded on `Package#stripped_parts` for caller
|
|
41
|
+
introspection. Set `Configuration#on_noncompliant_content = :raise`
|
|
42
|
+
to preserve the previous behavior for QA pipelines that want to
|
|
43
|
+
catch every deviation.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Loading then saving a DOCX with `[trash]/*.dat` or other
|
|
48
|
+
undeclared parts no longer fails with
|
|
49
|
+
`OPC-005 No content type declared` at save. The junk is stripped
|
|
50
|
+
at load and reported via `Package#stripped_parts`.
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- `FALLBACK_RAW_PART_CONTENT_TYPE` constant and the
|
|
55
|
+
`application/octet-stream` fallback in
|
|
56
|
+
`Docx::PackageSerialization#inject_raw_part_content_types`. The
|
|
57
|
+
fallback preserved junk that Word would strip and masked genuinely
|
|
58
|
+
missing declarations. (Introduced in 1.4.1; reverted before
|
|
59
|
+
broader release.)
|
|
60
|
+
|
|
11
61
|
## [1.4.0] - 2026-07-20
|
|
12
62
|
|
|
13
63
|
### Added
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Uniword
|
|
4
4
|
# Global runtime configuration for Uniword.
|
|
5
5
|
#
|
|
6
|
-
# Holds save-path policy as plain, explicitly typed
|
|
6
|
+
# Holds save-path policy as plain, explicitly typed attributes.
|
|
7
7
|
# This object is runtime policy only: it has no file-loading behavior
|
|
8
8
|
# (see ConfigurationLoader for external YAML config files) and no
|
|
9
9
|
# serialization behavior.
|
|
@@ -22,6 +22,15 @@ module Uniword
|
|
|
22
22
|
autoload :ConfigurationLoader,
|
|
23
23
|
"#{__dir__}/configuration/configuration_loader"
|
|
24
24
|
|
|
25
|
+
# Accepted values for `on_noncompliant_content`.
|
|
26
|
+
#
|
|
27
|
+
# `:strip` — Word-identical default: non-compliant parts are
|
|
28
|
+
# dropped at load and recorded on `Package#stripped_parts`.
|
|
29
|
+
# `:raise` — strict: parts are preserved; the save-time
|
|
30
|
+
# `PackageIntegrityChecker` raises `Uniword::ValidationError`
|
|
31
|
+
# with structured OPC-005 issues.
|
|
32
|
+
ON_NONCOMPLIANT_MODES = %i[strip raise].freeze
|
|
33
|
+
|
|
25
34
|
# Whether documents are validated when saved.
|
|
26
35
|
#
|
|
27
36
|
# @return [Boolean]
|
|
@@ -37,10 +46,16 @@ module Uniword
|
|
|
37
46
|
# @return [Boolean]
|
|
38
47
|
attr_reader :log_save_fixes
|
|
39
48
|
|
|
49
|
+
# Load-time policy for non-compliant parts (no content type
|
|
50
|
+
# declaration, OS artifact, ...).
|
|
51
|
+
#
|
|
52
|
+
# @return [Symbol] `:strip` (default) or `:raise`
|
|
53
|
+
attr_reader :on_noncompliant_content
|
|
54
|
+
|
|
40
55
|
# Create a configuration with default policy values.
|
|
41
56
|
#
|
|
42
57
|
# Defaults: validate_on_save: true, xsd_validation: false,
|
|
43
|
-
# log_save_fixes: true.
|
|
58
|
+
# log_save_fixes: true, on_noncompliant_content: :strip.
|
|
44
59
|
def initialize
|
|
45
60
|
reset!
|
|
46
61
|
end
|
|
@@ -52,6 +67,7 @@ module Uniword
|
|
|
52
67
|
@validate_on_save = true
|
|
53
68
|
@xsd_validation = false
|
|
54
69
|
@log_save_fixes = true
|
|
70
|
+
@on_noncompliant_content = :strip
|
|
55
71
|
self
|
|
56
72
|
end
|
|
57
73
|
|
|
@@ -82,6 +98,17 @@ module Uniword
|
|
|
82
98
|
@log_save_fixes = typed_boolean(value, :log_save_fixes)
|
|
83
99
|
end
|
|
84
100
|
|
|
101
|
+
# Set the non-compliant-content policy. Accepts symbols or
|
|
102
|
+
# strings; stored as a symbol.
|
|
103
|
+
#
|
|
104
|
+
# @param value [Symbol, String] `:strip` or `:raise`
|
|
105
|
+
# @return [Symbol] the value set
|
|
106
|
+
# @raise [ArgumentError] if value is not one of
|
|
107
|
+
# `ON_NONCOMPLIANT_MODES`
|
|
108
|
+
def on_noncompliant_content=(value)
|
|
109
|
+
@on_noncompliant_content = typed_mode(value, :on_noncompliant_content)
|
|
110
|
+
end
|
|
111
|
+
|
|
85
112
|
private
|
|
86
113
|
|
|
87
114
|
# Validate that a value is strictly boolean.
|
|
@@ -96,5 +123,31 @@ module Uniword
|
|
|
96
123
|
raise ArgumentError,
|
|
97
124
|
"#{name} must be true or false, got #{value.inspect}"
|
|
98
125
|
end
|
|
126
|
+
|
|
127
|
+
# Validate that a value is one of the accepted non-compliant modes.
|
|
128
|
+
#
|
|
129
|
+
# @param value [Object] value to check (Symbol or String)
|
|
130
|
+
# @param name [Symbol] attribute name used in the error message
|
|
131
|
+
# @return [Symbol] the value as a symbol
|
|
132
|
+
# @raise [ArgumentError] if value is not in ON_NONCOMPLIANT_MODES
|
|
133
|
+
def typed_mode(value, name)
|
|
134
|
+
sym = symbol_value(value)
|
|
135
|
+
return sym if ON_NONCOMPLIANT_MODES.include?(sym)
|
|
136
|
+
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"#{name} must be one of #{ON_NONCOMPLIANT_MODES.inspect}, " \
|
|
139
|
+
"got #{value.inspect}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Coerce a value to a Symbol when it is a Symbol or String.
|
|
143
|
+
#
|
|
144
|
+
# @param value [Object]
|
|
145
|
+
# @return [Symbol, nil] nil when value is neither Symbol nor String
|
|
146
|
+
def symbol_value(value)
|
|
147
|
+
return value if value.is_a?(Symbol)
|
|
148
|
+
return value.to_sym if value.is_a?(String)
|
|
149
|
+
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
99
152
|
end
|
|
100
153
|
end
|
|
@@ -18,6 +18,33 @@ module Uniword
|
|
|
18
18
|
map_element "Default", to: :defaults, render_nil: false
|
|
19
19
|
map_element "Override", to: :overrides, render_nil: false
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
# Resolve the content type declared for a package-relative path.
|
|
23
|
+
#
|
|
24
|
+
# Override entries win; the Default extension match is the
|
|
25
|
+
# fallback. Returns nil when neither matches (the part is
|
|
26
|
+
# non-compliant per OPC).
|
|
27
|
+
#
|
|
28
|
+
# @param path [String] package-relative path (e.g.
|
|
29
|
+
# "word/document.xml")
|
|
30
|
+
# @return [String, nil] content type, or nil when undeclared
|
|
31
|
+
def content_type_for(path)
|
|
32
|
+
override_content_type(path) || default_content_type(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def override_content_type(path)
|
|
38
|
+
part_name = "/#{path}"
|
|
39
|
+
overrides.find { |o| o.part_name == part_name }&.content_type
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def default_content_type(path)
|
|
43
|
+
ext = File.extname(path)[1..]
|
|
44
|
+
return nil unless ext
|
|
45
|
+
|
|
46
|
+
defaults.find { |d| d.extension == ext }&.content_type
|
|
47
|
+
end
|
|
21
48
|
end
|
|
22
49
|
end
|
|
23
50
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Classify a package-relative path as junk or legitimate.
|
|
6
|
+
#
|
|
7
|
+
# Used by `Docx::PartLoader::RawPartLoader` to decide which
|
|
8
|
+
# unclaimed ZIP entries to carry as `RawPart` and which to strip.
|
|
9
|
+
# The classifier is the single source of truth for the junk
|
|
10
|
+
# decision — adding new criteria means changing this class (or its
|
|
11
|
+
# pattern constants), not the loader.
|
|
12
|
+
#
|
|
13
|
+
# Classification is two-armed:
|
|
14
|
+
#
|
|
15
|
+
# 1. *OS/tooling artifact* — path matches a known pattern
|
|
16
|
+
# (`__MACOSX/`, `.DS_Store`, `Thumbs.db`, `._*`, `~$*`). These
|
|
17
|
+
# are never legitimate document content; matched unconditionally
|
|
18
|
+
# even when a Default content type happens to apply.
|
|
19
|
+
#
|
|
20
|
+
# 2. *Undeclared part* — no Override and no Default extension match
|
|
21
|
+
# in the loaded `ContentTypes::Types`, AND no modelled
|
|
22
|
+
# relationship targets the path. This is the OPC rule: every
|
|
23
|
+
# part must have a content type declaration.
|
|
24
|
+
#
|
|
25
|
+
# Both arms return a human-readable reason string; the loader
|
|
26
|
+
# records it on `Package#stripped_parts` for caller introspection.
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# classifier = JunkClassifier.new(
|
|
30
|
+
# content_types: package.content_types,
|
|
31
|
+
# relationships_by_path: rel_targets,
|
|
32
|
+
# )
|
|
33
|
+
# classifier.reason("[trash]/0000.dat")
|
|
34
|
+
# # => "No content type declaration and no referencing relationship"
|
|
35
|
+
# classifier.reason("word/document.xml") # => nil
|
|
36
|
+
class JunkClassifier
|
|
37
|
+
# Path patterns for OS / tooling artifacts that are never
|
|
38
|
+
# legitimate document content. Append to extend; the classifier
|
|
39
|
+
# iterates them in order.
|
|
40
|
+
OS_ARTIFACT_PATTERNS = [
|
|
41
|
+
/\A__MACOSX\//, # macOS zip metadata directory
|
|
42
|
+
/\A\.DS_Store\z/, # macOS Finder
|
|
43
|
+
/Thumbs\.db\z/, # Windows Explorer
|
|
44
|
+
/\A\._/, # macOS AppleDouble resource fork
|
|
45
|
+
/\A~\$/, # Office lock files
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
OS_ARTIFACT_REASON = "OS or tooling artifact"
|
|
49
|
+
|
|
50
|
+
UNDECLARED_REASON =
|
|
51
|
+
"No content type declaration and no referencing relationship"
|
|
52
|
+
|
|
53
|
+
# @param content_types [ContentTypes::Types, nil] the loaded
|
|
54
|
+
# content types model (nil when absent — every path is then
|
|
55
|
+
# undeclared unless a relationship targets it)
|
|
56
|
+
# @param relationships_by_path [Hash{String => true}, nil] set of
|
|
57
|
+
# paths targeted by some modelled relationship. Encoded as a
|
|
58
|
+
# hash for O(1) lookup; nil means "no relationships recorded"
|
|
59
|
+
def initialize(content_types:, relationships_by_path: nil)
|
|
60
|
+
@content_types = content_types
|
|
61
|
+
@relationships_by_path = relationships_by_path || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param path [String] package-relative path
|
|
65
|
+
# @return [String, nil] reason string when the path is junk;
|
|
66
|
+
# nil when it is legitimate
|
|
67
|
+
def reason(path)
|
|
68
|
+
os_artifact_reason(path) || undeclared_reason(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param path [String] package-relative path
|
|
72
|
+
# @return [Boolean] true when the path is junk
|
|
73
|
+
def junk?(path)
|
|
74
|
+
!reason(path).nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def os_artifact_reason(path)
|
|
80
|
+
OS_ARTIFACT_REASON if OS_ARTIFACT_PATTERNS.any? do |pattern|
|
|
81
|
+
pattern.match?(path)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def undeclared_reason(path)
|
|
86
|
+
return nil if content_type_declared?(path)
|
|
87
|
+
return nil if referenced_by_relationship?(path)
|
|
88
|
+
|
|
89
|
+
UNDECLARED_REASON
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def content_type_declared?(path)
|
|
93
|
+
@content_types&.content_type_for(path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def referenced_by_relationship?(path)
|
|
97
|
+
@relationships_by_path.key?(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/uniword/docx/package.rb
CHANGED
|
@@ -146,6 +146,29 @@ module Uniword
|
|
|
146
146
|
@applied_fixes ||= []
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
# Parts stripped at load because they were non-compliant (no
|
|
150
|
+
# content type declaration, OS artifact, ...). Populated by
|
|
151
|
+
# `Docx::PartLoader::RawPartLoader` when
|
|
152
|
+
# `Uniword.configuration.on_noncompliant_content` is `:strip`
|
|
153
|
+
# (the default). Empty in `:raise` mode and for packages
|
|
154
|
+
# constructed by hand.
|
|
155
|
+
#
|
|
156
|
+
# @return [Array<StrippedPart>] path + reason for every stripped
|
|
157
|
+
# part, in load order
|
|
158
|
+
def stripped_parts
|
|
159
|
+
@stripped_parts ||= []
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Record a stripped part. Append-only; used by the loader.
|
|
163
|
+
#
|
|
164
|
+
# @param path [String] package-relative path
|
|
165
|
+
# @param reason [String] why the part was stripped (from
|
|
166
|
+
# `JunkClassifier#reason`)
|
|
167
|
+
# @return [void]
|
|
168
|
+
def add_stripped_part(path:, reason:)
|
|
169
|
+
stripped_parts << StrippedPart.new(path: path, reason: reason)
|
|
170
|
+
end
|
|
171
|
+
|
|
149
172
|
# Load DOCX package from file
|
|
150
173
|
#
|
|
151
174
|
# @param path [String] Path to .docx file
|
|
@@ -400,6 +400,11 @@ document_rels)
|
|
|
400
400
|
# reconciliation untouched (non-standard entries are preserved);
|
|
401
401
|
# a part whose content type came from a Default entry needs an
|
|
402
402
|
# Override once the reconciler rebuilds the standard Defaults.
|
|
403
|
+
#
|
|
404
|
+
# Raw parts always carry a non-nil content type: the loader
|
|
405
|
+
# strips undeclared parts at load (default `:strip` policy), and
|
|
406
|
+
# API-created raw parts without a content type are a programmer
|
|
407
|
+
# error that the save-time gate catches as OPC-005.
|
|
403
408
|
def inject_raw_part_content_types(content_types)
|
|
404
409
|
return if raw_parts.empty?
|
|
405
410
|
|
|
@@ -415,7 +420,8 @@ document_rels)
|
|
|
415
420
|
next if content_types.overrides.any? { |o| o.part_name == part_name }
|
|
416
421
|
|
|
417
422
|
content_types.overrides << Uniword::ContentTypes::Override.new(
|
|
418
|
-
part_name: part_name,
|
|
423
|
+
part_name: part_name,
|
|
424
|
+
content_type: part.content_type,
|
|
419
425
|
)
|
|
420
426
|
end
|
|
421
427
|
end
|
|
@@ -17,6 +17,13 @@ module Uniword
|
|
|
17
17
|
# sidecar) at a non-standard location, or when it is a customXml
|
|
18
18
|
# item rels sidecar consumed by CustomXmlLoader.
|
|
19
19
|
#
|
|
20
|
+
# Non-compliant parts (no content type declaration, OS artifact,
|
|
21
|
+
# ...) are stripped at load when
|
|
22
|
+
# `Uniword.configuration.on_noncompliant_content` is `:strip`
|
|
23
|
+
# (the default, matching Word's behavior). In `:raise` mode the
|
|
24
|
+
# parts are preserved and the save-time integrity gate raises
|
|
25
|
+
# with structured OPC-005 issues.
|
|
26
|
+
#
|
|
20
27
|
# Bytes are re-read from the original ZIP when available (the
|
|
21
28
|
# extracted hash may carry corrupted UTF-8 binary); content
|
|
22
29
|
# types come from the loaded [Content_Types].xml model.
|
|
@@ -32,21 +39,84 @@ module Uniword
|
|
|
32
39
|
# by definition matching)
|
|
33
40
|
# @return [void]
|
|
34
41
|
def load(context, _definition)
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
preservable, stripped = partition_unclaimed(context)
|
|
43
|
+
|
|
44
|
+
record_stripped(context, stripped)
|
|
45
|
+
return if preservable.empty?
|
|
37
46
|
|
|
38
|
-
bytes = raw_bytes(context,
|
|
39
|
-
|
|
47
|
+
bytes = raw_bytes(context, preservable)
|
|
48
|
+
rels_by_target = relationships_by_target(context.package)
|
|
49
|
+
|
|
50
|
+
preservable.each do |path|
|
|
40
51
|
next unless bytes[path]
|
|
41
52
|
|
|
42
53
|
context.package.raw_parts[path] = build_part(
|
|
43
|
-
context.package, path, bytes[path]
|
|
54
|
+
context.package, path, bytes[path], rels_by_target
|
|
44
55
|
)
|
|
45
56
|
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
59
|
private
|
|
49
60
|
|
|
61
|
+
# Split unclaimed paths into preservable and stripped based on
|
|
62
|
+
# the configured policy. In `:raise` mode everything is
|
|
63
|
+
# preserved (existing behavior); the save-time gate handles
|
|
64
|
+
# non-compliance.
|
|
65
|
+
#
|
|
66
|
+
# @return [Array(Array<String>, Array<String>)] preservable
|
|
67
|
+
# paths and stripped paths (each in zip_content order)
|
|
68
|
+
def partition_unclaimed(context)
|
|
69
|
+
unclaimed = unclaimed_paths(context)
|
|
70
|
+
return [unclaimed, []] if raise_mode?
|
|
71
|
+
|
|
72
|
+
classifier = build_classifier(context.package)
|
|
73
|
+
unclaimed.partition { |path| classifier.reason(path).nil? }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] true when the configured policy is :raise
|
|
77
|
+
def raise_mode?
|
|
78
|
+
Uniword.configuration.on_noncompliant_content == :raise
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Record stripped parts on the package and log each one when
|
|
82
|
+
# save-fix logging is enabled.
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
def record_stripped(context, stripped)
|
|
86
|
+
return if stripped.empty?
|
|
87
|
+
|
|
88
|
+
classifier = build_classifier(context.package)
|
|
89
|
+
stripped.each do |path|
|
|
90
|
+
reason = classifier.reason(path)
|
|
91
|
+
context.package.add_stripped_part(path: path, reason: reason)
|
|
92
|
+
log_strip(path, reason)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [JunkClassifier] classifier wired to the package's
|
|
97
|
+
# content types and relationship targets
|
|
98
|
+
def build_classifier(package)
|
|
99
|
+
JunkClassifier.new(
|
|
100
|
+
content_types: package.content_types,
|
|
101
|
+
relationships_by_path: relationships_by_target(package),
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Hash{String => Array<Ooxml::Relationships::Relationship>}]
|
|
106
|
+
# target path => relationships referencing it. Multiple rels
|
|
107
|
+
# may target the same path; the first is used for metadata.
|
|
108
|
+
# The classifier uses `key?` only.
|
|
109
|
+
def relationships_by_target(package)
|
|
110
|
+
rels_collections(package).each_with_object({}) do |(rels, base), memo|
|
|
111
|
+
rels&.relationships&.each do |rel|
|
|
112
|
+
target = rel.target.to_s
|
|
113
|
+
next if target.empty?
|
|
114
|
+
|
|
115
|
+
(memo[resolve_target(base, target)] ||= []) << rel
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
50
120
|
# @return [Array<String>] ZIP entries claimed by no registry
|
|
51
121
|
# loader, in zip_content order
|
|
52
122
|
def unclaimed_paths(context)
|
|
@@ -71,8 +141,8 @@ module Uniword
|
|
|
71
141
|
end
|
|
72
142
|
|
|
73
143
|
# @return [RawPart]
|
|
74
|
-
def build_part(package, path, content)
|
|
75
|
-
rel =
|
|
144
|
+
def build_part(package, path, content, rels_by_target)
|
|
145
|
+
rel = first_referencing_relationship(rels_by_target, path)
|
|
76
146
|
RawPart.new(
|
|
77
147
|
path: path,
|
|
78
148
|
content: content,
|
|
@@ -105,44 +175,23 @@ module Uniword
|
|
|
105
175
|
end
|
|
106
176
|
|
|
107
177
|
# Content type the source [Content_Types].xml declared for the
|
|
108
|
-
# part
|
|
178
|
+
# part. Delegates to `ContentTypes::Types#content_type_for`
|
|
179
|
+
# (single source of truth for the lookup).
|
|
109
180
|
#
|
|
110
181
|
# @return [String, nil] nil when the source declared neither
|
|
111
182
|
def content_type_for(package, path)
|
|
112
183
|
content_types = package.content_types
|
|
113
184
|
return nil unless content_types
|
|
114
185
|
|
|
115
|
-
|
|
116
|
-
default_type(content_types, path)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def override_type(content_types, path)
|
|
120
|
-
part_name = "/#{path}"
|
|
121
|
-
content_types.overrides.find do |o|
|
|
122
|
-
o.part_name == part_name
|
|
123
|
-
end&.content_type
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def default_type(content_types, path)
|
|
127
|
-
ext = File.extname(path)[1..]
|
|
128
|
-
return nil unless ext
|
|
129
|
-
|
|
130
|
-
content_types.defaults.find do |d|
|
|
131
|
-
d.extension == ext
|
|
132
|
-
end&.content_type
|
|
186
|
+
content_types.content_type_for(path)
|
|
133
187
|
end
|
|
134
188
|
|
|
135
|
-
# The first relationship
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
resolve_target(base_dir, r.target.to_s) == path
|
|
142
|
-
end
|
|
143
|
-
return rel if rel
|
|
144
|
-
end
|
|
145
|
-
nil
|
|
189
|
+
# The first relationship referencing the given path, or nil.
|
|
190
|
+
# Recorded as metadata only; the rel itself stays in its rels
|
|
191
|
+
# collection.
|
|
192
|
+
def first_referencing_relationship(rels_by_target, path)
|
|
193
|
+
rels = rels_by_target[path]
|
|
194
|
+
rels.is_a?(Array) ? rels.first : nil
|
|
146
195
|
end
|
|
147
196
|
|
|
148
197
|
# [rels collection, base directory] pairs mirroring the rels
|
|
@@ -177,6 +226,15 @@ module Uniword
|
|
|
177
226
|
end
|
|
178
227
|
segments.join("/")
|
|
179
228
|
end
|
|
229
|
+
|
|
230
|
+
# @return [void]
|
|
231
|
+
def log_strip(path, reason)
|
|
232
|
+
return unless Uniword.configuration.log_save_fixes
|
|
233
|
+
|
|
234
|
+
Uniword.logger&.info do
|
|
235
|
+
"Stripped non-compliant part #{path} (#{reason})"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
180
238
|
end
|
|
181
239
|
end
|
|
182
240
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Docx
|
|
5
|
+
# Reporting record for a part stripped at load time.
|
|
6
|
+
#
|
|
7
|
+
# Created by `Docx::PartLoader` (in `:strip` policy mode) for
|
|
8
|
+
# every ZIP entry the `JunkClassifier` flagged. Stored on
|
|
9
|
+
# `Package#stripped_parts` for caller introspection.
|
|
10
|
+
#
|
|
11
|
+
# Lightweight by design: no lutaml-model, no XML serialization.
|
|
12
|
+
# These are load-time metadata, never written to a package.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# part = StrippedPart.new(
|
|
16
|
+
# path: "[trash]/0000.dat",
|
|
17
|
+
# reason: "No content type declaration and no referencing " \
|
|
18
|
+
# "relationship",
|
|
19
|
+
# )
|
|
20
|
+
# part.path # => "[trash]/0000.dat"
|
|
21
|
+
# part.reason # => "No content type declaration..."
|
|
22
|
+
class StrippedPart
|
|
23
|
+
attr_reader :path, :reason
|
|
24
|
+
|
|
25
|
+
# @param path [String] package-relative path of the stripped part
|
|
26
|
+
# @param reason [String] why the part was stripped (human-readable,
|
|
27
|
+
# produced by `JunkClassifier#reason`)
|
|
28
|
+
def initialize(path:, reason:)
|
|
29
|
+
@path = path
|
|
30
|
+
@reason = reason
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Value-object equality by path + reason.
|
|
34
|
+
#
|
|
35
|
+
# @param other [Object]
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def eql?(other)
|
|
38
|
+
other.is_a?(StrippedPart) && path == other.path &&
|
|
39
|
+
reason == other.reason
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Combined with `eql?` for Hash/Set membership.
|
|
43
|
+
#
|
|
44
|
+
# @return [Integer]
|
|
45
|
+
def hash
|
|
46
|
+
[path, reason].hash
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param other [Object]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def ==(other)
|
|
52
|
+
eql?(other)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/uniword/docx.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Uniword
|
|
|
10
10
|
autoload :IdAllocator, "#{__dir__}/docx/id_allocator"
|
|
11
11
|
autoload :Part, "#{__dir__}/docx/part"
|
|
12
12
|
autoload :RawPart, "#{__dir__}/docx/raw_part"
|
|
13
|
+
autoload :StrippedPart, "#{__dir__}/docx/stripped_part"
|
|
14
|
+
autoload :JunkClassifier, "#{__dir__}/docx/junk_classifier"
|
|
13
15
|
autoload :ChartPart, "#{__dir__}/docx/chart_part"
|
|
14
16
|
autoload :ImagePart, "#{__dir__}/docx/image_part"
|
|
15
17
|
autoload :HeaderFooterPart, "#{__dir__}/docx/header_footer_part"
|
data/lib/uniword/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: uniword
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -823,6 +823,7 @@ files:
|
|
|
823
823
|
- lib/uniword/docx/header_footer_view.rb
|
|
824
824
|
- lib/uniword/docx/id_allocator.rb
|
|
825
825
|
- lib/uniword/docx/image_part.rb
|
|
826
|
+
- lib/uniword/docx/junk_classifier.rb
|
|
826
827
|
- lib/uniword/docx/package.rb
|
|
827
828
|
- lib/uniword/docx/package_defaults.rb
|
|
828
829
|
- lib/uniword/docx/package_integrity_checker.rb
|
|
@@ -852,6 +853,7 @@ files:
|
|
|
852
853
|
- lib/uniword/docx/reconciler/referential_integrity.rb
|
|
853
854
|
- lib/uniword/docx/reconciler/tables.rb
|
|
854
855
|
- lib/uniword/docx/reconciler/theme.rb
|
|
856
|
+
- lib/uniword/docx/stripped_part.rb
|
|
855
857
|
- lib/uniword/drawingml.rb
|
|
856
858
|
- lib/uniword/drawingml/adjust_value_list.rb
|
|
857
859
|
- lib/uniword/drawingml/alpha.rb
|