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 +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/uniword/batch/operation/file_result.rb +39 -0
- data/lib/uniword/batch/operation/repair_task.rb +34 -0
- data/lib/uniword/batch/operation/report.rb +66 -0
- data/lib/uniword/batch/operation/runner.rb +30 -0
- data/lib/uniword/batch/operation/task.rb +40 -0
- data/lib/uniword/batch/operation/verify_task.rb +27 -0
- data/lib/uniword/batch/operation.rb +22 -0
- data/lib/uniword/batch.rb +1 -0
- data/lib/uniword/caption/caption_builder.rb +120 -0
- data/lib/uniword/caption/counter.rb +61 -0
- data/lib/uniword/caption/cross_reference.rb +44 -0
- data/lib/uniword/caption.rb +24 -0
- data/lib/uniword/diff/semantic/change.rb +66 -0
- data/lib/uniword/diff/semantic/engine.rb +31 -0
- data/lib/uniword/diff/semantic/paragraph_comparator.rb +195 -0
- data/lib/uniword/diff/semantic/result.rb +53 -0
- data/lib/uniword/diff/semantic.rb +26 -0
- data/lib/uniword/diff.rb +1 -0
- data/lib/uniword/plugin/cli_command.rb +30 -0
- data/lib/uniword/plugin/loader.rb +43 -0
- data/lib/uniword/plugin/registry.rb +83 -0
- data/lib/uniword/plugin/transformer.rb +50 -0
- data/lib/uniword/plugin/validator.rb +34 -0
- data/lib/uniword/plugin.rb +25 -0
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/document_styling.rb +40 -0
- data/lib/uniword.rb +6 -0
- metadata +24 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d06b448460972e577f4d1f22b11441d07949397a06fbe9002db6c4e71d091cc
|
|
4
|
+
data.tar.gz: ae04c58467dd36288a5fdf08662496c5d99d2b2da1a7c8fa648fbedd234d9ad2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|