uniword 1.5.2 → 1.5.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9574cce7758124ff340199b7b07e95bc332e86ed1428e663eff2ff5059bc044
4
- data.tar.gz: 2d80ed18e0289d232422c9c882201eeed2cded201df55c3ae887945b1ec9d0bf
3
+ metadata.gz: 8d06b448460972e577f4d1f22b11441d07949397a06fbe9002db6c4e71d091cc
4
+ data.tar.gz: ae04c58467dd36288a5fdf08662496c5d99d2b2da1a7c8fa648fbedd234d9ad2
5
5
  SHA512:
6
- metadata.gz: 05f384f87289e6325f6761a90afec1f08a9129bfe09b4b1bc381b0dd23bb6acd81f438f423c7463f3b609dba2c81d12ef3a6eef900b766816c69bcecb2a24c80
7
- data.tar.gz: 185871d7a5926e6b1e4708cbf24169642cc4e4096edd416f184399c887ef4a6c3db40bb3dc88f3b7fb025c7826bf2a23aa86b13b9e8660ce95f35272d0282f56
6
+ metadata.gz: 4d3146558aaf9df9228b7d54064b31aa5c050b3262542aae4075dbeddedea6c53b864c5bc41b065d26289f9c2196e8ee331ca24893b26bea0212f32b39c3fe34
7
+ data.tar.gz: fcfa682da31b88784fd3ccabd152f5271f307f6046e5572093bc04789777c1dac23008ad8794d664282128301671c33b411660d5b12cdd5562773481e2adcc48
data/CHANGELOG.md CHANGED
@@ -10,6 +10,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ### Added
12
12
 
13
+ - `Uniword::Caption` module — auto-numbered figure/table/equation
14
+ captions with `Caption::Counter`, `Caption::CaptionBuilder`
15
+ (SEQ field + bookmark), and `Caption::CrossReference` (REF
16
+ fldSimple).
17
+ - `DocumentRoot#caption_counter`, `#add_caption(label:, text:,`
18
+ ` separator:)`, `#cross_reference_to(bookmark_name)` Ruby API.
19
+ - `Uniword::Plugin` module — extensibility surface with
20
+ `Plugin::Validator`, `Plugin::Transformer`, `Plugin::CliCommand`
21
+ base classes and `Plugin::Registry` for registration.
22
+ `Plugin::Loader` discovers plugins via `Gem.find_files`.
23
+ - `Uniword::Diff::Semantic` module — element-level diff with
24
+ change classification (`:added`, `:removed`, `:modified`, `:moved`)
25
+ and sub-classification for modifications (`:text`, `:format`,
26
+ `:structure`). LCS-based paragraph alignment.
27
+ - `Uniword::Batch::Operation` module — parallel-by-design runner
28
+ for batch CLI operations. Includes `RepairTask` and `VerifyTask`;
29
+ extensible via `Operation::Task` subclass + `Operation::Runner`.
30
+
31
+ ### Changed
32
+
33
+ - `Uniword::Diff` module now autoloads `Semantic` (element-level
34
+ diff sits alongside the existing text-level `DocumentDiffer`).
35
+ - `Uniword::Batch` module now autoloads `Operation` (sibling to
36
+ the existing staged-pipeline `DocumentProcessor`).
37
+
38
+ ### Added (from prior PR — kept here for Unreleased record)
39
+
13
40
  - `DocumentRoot#find_replace(pattern, replacement, scope:, ignore_case:)`
14
41
  — Word's Home → Replace dialog as an API. Replaces every
15
42
  non-overlapping match across one or more scopes (`body`, `headers`,
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # Aggregated result for one file in a batch run.
7
+ class FileResult
8
+ # @return [String] absolute or relative path of the input file
9
+ attr_reader :path
10
+
11
+ # @return [Symbol] :success, :failure
12
+ attr_reader :status
13
+
14
+ # @return [Integer] operation-specific metric (e.g. repair
15
+ # count, verify issue count)
16
+ attr_reader :metric
17
+
18
+ # @return [String, nil] error message when status is :failure
19
+ attr_reader :error
20
+
21
+ # @param path [String]
22
+ # @param status [Symbol]
23
+ # @param metric [Integer]
24
+ # @param error [String, nil]
25
+ def initialize(path:, status:, metric: 0, error: nil)
26
+ @path = path
27
+ @status = status
28
+ @metric = metric
29
+ @error = error
30
+ end
31
+
32
+ # @return [Boolean]
33
+ def success?
34
+ @status == :success
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # `uniword repair` for one file. Saves the repaired copy to
7
+ # `output_path`, returns the count of applied fixes as the
8
+ # metric.
9
+ class RepairTask < Task
10
+ # @param output_dir [String] directory to write repaired copies
11
+ def initialize(output_dir:)
12
+ @output_dir = output_dir
13
+ end
14
+
15
+ # @return [Symbol]
16
+ def name
17
+ :repair
18
+ end
19
+
20
+ # @param path [String]
21
+ # @return [FileResult]
22
+ def run(path)
23
+ doc = Uniword::DocumentFactory.from_file(path)
24
+ output_path = File.join(@output_dir, File.basename(path))
25
+
26
+ doc.save(output_path)
27
+ success(path: path)
28
+ rescue StandardError => e
29
+ failure(path: path, error: message_of(e))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # Aggregated report for a batch run.
7
+ class Report
8
+ attr_reader :results, :operation_name
9
+
10
+ # @param operation_name [Symbol]
11
+ def initialize(operation_name:)
12
+ @operation_name = operation_name
13
+ @results = []
14
+ end
15
+
16
+ # @param result [FileResult]
17
+ # @return [void]
18
+ def add(result)
19
+ @results << result
20
+ end
21
+
22
+ # Total file count processed.
23
+ #
24
+ # @return [Integer]
25
+ def count
26
+ @results.length
27
+ end
28
+
29
+ # Count of successful files.
30
+ #
31
+ # @return [Integer]
32
+ def success_count
33
+ @results.count(&:success?)
34
+ end
35
+
36
+ # Count of failed files.
37
+ #
38
+ # @return [Integer]
39
+ def failure_count
40
+ @results.count { |r| !r.success? }
41
+ end
42
+
43
+ # Sum of per-file metrics (e.g. total repairs applied).
44
+ #
45
+ # @return [Integer]
46
+ def total_metric
47
+ @results.sum(&:metric)
48
+ end
49
+
50
+ # True when every file succeeded.
51
+ #
52
+ # @return [Boolean]
53
+ def all_success?
54
+ @results.all?(&:success?)
55
+ end
56
+
57
+ # List of failed file paths.
58
+ #
59
+ # @return [Array<String>]
60
+ def failed_paths
61
+ @results.reject(&:success?).map(&:path)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # Parallel runner for a Task across many files.
7
+ #
8
+ class Runner
9
+ # @param task [Task]
10
+ # @param paths [Array<String>]
11
+ def initialize(task:, paths:)
12
+ @task = task
13
+ @paths = paths
14
+ end
15
+
16
+ # Run the task on every path. Files are processed serially
17
+ # in v1 (parallelism deferred — see TODO.tier-2/10).
18
+ #
19
+ # @return [Report]
20
+ def run
21
+ report = Report.new(operation_name: @task.name)
22
+ @paths.each do |path|
23
+ report.add(@task.run(path))
24
+ end
25
+ report
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # Abstract base class for one batchable operation on one file.
7
+ # Subclasses implement `run(path) -> FileResult`.
8
+ class Task
9
+ # @return [Symbol] task name shown in reports
10
+ def name
11
+ raise NotImplementedError
12
+ end
13
+
14
+ # Run the operation on one file. Returns a FileResult.
15
+ #
16
+ # @param path [String] input file path
17
+ # @return [FileResult]
18
+ def run(path)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ protected
23
+
24
+ def success(path:, metric: 0)
25
+ FileResult.new(path: path, status: :success, metric: metric)
26
+ end
27
+
28
+ def failure(path:, error:, metric: 0)
29
+ FileResult.new(path: path, status: :failure,
30
+ metric: metric, error: error)
31
+ end
32
+
33
+ # Render an exception's message (stripping noisy backtrace).
34
+ def message_of(error)
35
+ "#{error.class}: #{error.message}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ module Operation
6
+ # `uniword verify` for one file. Returns the issue count as the
7
+ # metric (0 means clean).
8
+ class VerifyTask < Task
9
+ # @return [Symbol]
10
+ def name
11
+ :verify
12
+ end
13
+
14
+ # @param path [String]
15
+ # @return [FileResult]
16
+ def run(path)
17
+ report = Uniword::Verification.verify(path)
18
+ issue_count = report.issues.length
19
+ status = report.valid? ? :success : :failure
20
+ FileResult.new(path: path, status: status, metric: issue_count)
21
+ rescue StandardError => e
22
+ failure(path: path, error: message_of(e))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Batch
5
+ # Operation-style batching: run a top-level CLI operation
6
+ # (repair, verify, find-replace, diff) across many files in
7
+ # parallel with structured per-file reports. Distinct from
8
+ # `Batch::DocumentProcessor` (staged pipeline processing) and
9
+ # from `Batch::ProcessingStage`.
10
+ #
11
+ # Open/closed: a new operation = new subclass of `Operation::Task`
12
+ # + registration in `Operation::Runner::TASKS`.
13
+ module Operation
14
+ autoload :Task, "#{__dir__}/operation/task"
15
+ autoload :FileResult, "#{__dir__}/operation/file_result"
16
+ autoload :Report, "#{__dir__}/operation/report"
17
+ autoload :Runner, "#{__dir__}/operation/runner"
18
+ autoload :RepairTask, "#{__dir__}/operation/repair_task"
19
+ autoload :VerifyTask, "#{__dir__}/operation/verify_task"
20
+ end
21
+ end
22
+ end
data/lib/uniword/batch.rb CHANGED
@@ -15,5 +15,6 @@ module Uniword
15
15
  autoload :UpdateMetadataStage,
16
16
  "#{__dir__}/batch/stages/update_metadata_stage"
17
17
  autoload :ValidateLinksStage, "#{__dir__}/batch/stages/validate_links_stage"
18
+ autoload :Operation, "#{__dir__}/batch/operation"
18
19
  end
19
20
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Caption
5
+ # Builds a Caption-styled paragraph for one caption.
6
+ #
7
+ # Layout of the produced paragraph:
8
+ #
9
+ # <w:p>
10
+ # <w:pPr><w:pStyle w:val="Caption"/></w:pPr>
11
+ # <w:bookmarkStart w:id="N" w:name="_Figure1"/>
12
+ # <w:r><w:t xml:space="preserve">Figure </w:t></w:r>
13
+ # <w:fldSimple w:instr=" SEQ Figure \* ARABIC ">
14
+ # <w:r><w:t>1</w:t></w:r>
15
+ # </w:fldSimple>
16
+ # <w:r><w:t xml:space="preserve">: Caption text</w:t></w:r>
17
+ # <w:bookmarkEnd w:id="N"/>
18
+ # </w:p>
19
+ #
20
+ # The bookmark name is `_Figure1`, `_Table2`, etc. — `_` prefix
21
+ # matches Word's convention for hidden bookmarks.
22
+ class CaptionBuilder
23
+ DEFAULT_SEPARATOR = ": "
24
+
25
+ # @param counter [Counter]
26
+ def initialize(counter)
27
+ @counter = counter
28
+ end
29
+
30
+ # Build a caption paragraph.
31
+ #
32
+ # @param label [String] e.g. "Figure"
33
+ # @param text [String] caption body text
34
+ # @param separator [String] between label/number and body
35
+ # (default ": ")
36
+ # @param bookmark_id [Integer, nil] bookmark id; auto-allocated
37
+ # when nil
38
+ # @return [Array<Wordprocessingml::Paragraph, String>] the
39
+ # paragraph and the bookmark name (caller appends the
40
+ # paragraph to the document)
41
+ def build(label:, text:, separator: DEFAULT_SEPARATOR,
42
+ bookmark_id: nil)
43
+ sequence = @counter.next_value(label)
44
+ bookmark_name = bookmark_name_for(label, sequence)
45
+ bookmark_id ||= sequence
46
+
47
+ [
48
+ build_paragraph(label: label,
49
+ sequence: sequence,
50
+ text: text,
51
+ separator: separator,
52
+ bookmark_name: bookmark_name,
53
+ bookmark_id: bookmark_id),
54
+ bookmark_name,
55
+ ]
56
+ end
57
+
58
+ private
59
+
60
+ def build_paragraph(label:, sequence:, text:, separator:,
61
+ bookmark_name:, bookmark_id:)
62
+ para = Wordprocessingml::Paragraph.new
63
+ para.properties = paragraph_properties
64
+ para.bookmark_starts = [start_bookmark(bookmark_id, bookmark_name)]
65
+ para.runs = [
66
+ label_run("#{label} "),
67
+ seq_field(label, sequence),
68
+ body_run("#{separator}#{text}"),
69
+ ]
70
+ para.bookmark_ends = [end_bookmark(bookmark_id)]
71
+ para
72
+ end
73
+
74
+ def paragraph_properties
75
+ props = Wordprocessingml::ParagraphProperties.new
76
+ props.style = "Caption"
77
+ props
78
+ end
79
+
80
+ def start_bookmark(id, name)
81
+ Wordprocessingml::BookmarkStart.new(id: id.to_s, name: name)
82
+ end
83
+
84
+ def end_bookmark(id)
85
+ Wordprocessingml::BookmarkEnd.new(id: id.to_s)
86
+ end
87
+
88
+ def label_run(text)
89
+ run_with_text(text)
90
+ end
91
+
92
+ def body_run(text)
93
+ run_with_text(text)
94
+ end
95
+
96
+ def run_with_text(text)
97
+ Wordprocessingml::Run.new(text: [text_element(text)])
98
+ end
99
+
100
+ def text_element(content)
101
+ Wordprocessingml::Text.new(content: content, xml_space: "preserve")
102
+ end
103
+
104
+ def seq_field(label, sequence)
105
+ Wordprocessingml::SimpleField.new(
106
+ instr: seq_instruction(label),
107
+ runs: [run_with_text(sequence.to_s)],
108
+ )
109
+ end
110
+
111
+ def seq_instruction(label)
112
+ " SEQ #{label} \\* ARABIC "
113
+ end
114
+
115
+ def bookmark_name_for(label, sequence)
116
+ "_#{label}#{sequence}"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Caption
5
+ # Label-keyed counter for figure/table/equation captions.
6
+ # Persisted on the document via `DocumentRoot#caption_counters`;
7
+ # each label starts at 1 and increments on every `next_value`.
8
+ #
9
+ # Open/closed: any label string works — adding a new label
10
+ # category is just using it.
11
+ class Counter
12
+ # @return [Hash{String => Integer}]
13
+ attr_reader :counts
14
+
15
+ def initialize
16
+ @counts = Hash.new { |h, k| h[k] = 0 }
17
+ end
18
+
19
+ # @param label [String] e.g. "Figure", "Table", "Equation"
20
+ # @return [Integer] the next sequence value for this label
21
+ def next_value(label)
22
+ label = normalize_label(label)
23
+ @counts[label] += 1
24
+ end
25
+
26
+ # Current count without incrementing.
27
+ #
28
+ # @param label [String]
29
+ # @return [Integer]
30
+ def current(label)
31
+ @counts[normalize_label(label)]
32
+ end
33
+
34
+ # Reset one or all counters.
35
+ #
36
+ # @param label [String, nil] nil resets all
37
+ # @return [void]
38
+ def reset(label = nil)
39
+ if label.nil?
40
+ @counts.clear
41
+ return
42
+ end
43
+
44
+ @counts.delete(normalize_label(label))
45
+ end
46
+
47
+ # All labels currently tracked.
48
+ #
49
+ # @return [Array<String>]
50
+ def labels
51
+ @counts.keys
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_label(label)
57
+ label.to_s
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Caption
5
+ # Builds a `REF` fldSimple that references a bookmark, producing
6
+ # display text like "Figure 3" or "Table 2" when Word renders it.
7
+ #
8
+ # For Word to refresh these fields on open, set
9
+ # `Settings#update_fields` (the `w:updateFields` element) to
10
+ # `UpdateFields.new` before saving.
11
+ class CrossReference
12
+ # @param bookmark_name [String] target bookmark name
13
+ def initialize(bookmark_name)
14
+ @bookmark_name = bookmark_name
15
+ end
16
+
17
+ # @return [Wordprocessingml::SimpleField]
18
+ def build
19
+ Wordprocessingml::SimpleField.new(
20
+ instr: instruction,
21
+ runs: [placeholder_run],
22
+ )
23
+ end
24
+
25
+ private
26
+
27
+ # The `\h` switch makes the reference a hyperlink (clickable).
28
+ def instruction
29
+ " REF #{@bookmark_name} \\h "
30
+ end
31
+
32
+ def placeholder_run
33
+ Wordprocessingml::Run.new(text: [placeholder_text])
34
+ end
35
+
36
+ # Word replaces this with the bookmark's display text on
37
+ # field update. uniword writes the bookmark name as a
38
+ # placeholder so the field is never empty before refresh.
39
+ def placeholder_text
40
+ Wordprocessingml::Text.new(content: @bookmark_name)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ # Auto-numbered figure/table/equation captions with cross-references
5
+ # and Tables of Figures.
6
+ #
7
+ # Three pieces, all model-driven:
8
+ #
9
+ # - `Caption::Counter` — label-keyed counters (Figure, Table,
10
+ # Equation) persisted on the document. Reset to 1 at the start
11
+ # of each document load.
12
+ # - `Caption::CaptionBuilder` — builds a Caption-styled paragraph
13
+ # with a SEQ field, returns the assigned bookmark name.
14
+ # - `Caption::CrossReference` — builds a REF fldSimple targeting a
15
+ # named bookmark.
16
+ #
17
+ # The TOC of figures is just the existing TOC engine with `\c`
18
+ # instead of `\o`; see `Toc::FigureEntryBuilder`.
19
+ module Caption
20
+ autoload :Counter, "#{__dir__}/caption/counter"
21
+ autoload :CaptionBuilder, "#{__dir__}/caption/caption_builder"
22
+ autoload :CrossReference, "#{__dir__}/caption/cross_reference"
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # One classified change. Severity defaults to :info; classifiers
7
+ # can promote to :warning or :error.
8
+ class Change
9
+ KINDS = %i[added removed modified moved].freeze
10
+ MODIFIERS = %i[text format structure].freeze
11
+
12
+ # @return [Symbol] one of KINDS
13
+ attr_reader :kind
14
+
15
+ # @return [Symbol, nil] for :modified, one of MODIFIERS
16
+ attr_reader :modifier
17
+
18
+ # @return [Integer, nil] paragraph index in old (nil when
19
+ # the paragraph is new)
20
+ attr_reader :old_index
21
+
22
+ # @return [Integer, nil] paragraph index in new (nil when
23
+ # the paragraph was removed)
24
+ attr_reader :new_index
25
+
26
+ # @return [String, nil] human-readable summary
27
+ attr_reader :description
28
+
29
+ # @param kind [Symbol]
30
+ # @param modifier [Symbol, nil]
31
+ # @param old_index [Integer, nil]
32
+ # @param new_index [Integer, nil]
33
+ # @param description [String, nil]
34
+ def initialize(kind:, modifier: nil, old_index: nil,
35
+ new_index: nil, description: nil)
36
+ unless KINDS.include?(kind)
37
+ raise ArgumentError,
38
+ "unknown kind #{kind.inspect}"
39
+ end
40
+ if modifier && !MODIFIERS.include?(modifier)
41
+ raise ArgumentError, "unknown modifier #{modifier.inspect}"
42
+ end
43
+
44
+ @kind = kind
45
+ @modifier = modifier
46
+ @old_index = old_index
47
+ @new_index = new_index
48
+ @description = description
49
+ end
50
+
51
+ # @return [Hash]
52
+ def to_h
53
+ { kind: kind, modifier: modifier,
54
+ old_index: old_index, new_index: new_index,
55
+ description: description }
56
+ end
57
+
58
+ # @param other [Object]
59
+ # @return [Boolean]
60
+ def ==(other)
61
+ other.is_a?(Change) && to_h == other.to_h
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Diff
5
+ module Semantic
6
+ # Orchestrates element-level diff between two documents.
7
+ # Currently compares body paragraphs; future comparators
8
+ # (tables, images, styles) plug in via `COMPARATORS`.
9
+ class Engine
10
+ # @param old_doc [Wordprocessingml::DocumentRoot]
11
+ # @param new_doc [Wordprocessingml::DocumentRoot]
12
+ def initialize(old_doc, new_doc)
13
+ @old_doc = old_doc
14
+ @new_doc = new_doc
15
+ end
16
+
17
+ # Run every registered comparator and aggregate the changes.
18
+ #
19
+ # @return [Result]
20
+ def diff
21
+ result = Result.new
22
+ ParagraphComparator.each_change(@old_doc.paragraphs,
23
+ @new_doc.paragraphs) do |change|
24
+ result.add(change)
25
+ end
26
+ result
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end