prosereflect 0.1.1 → 0.3.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/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +97 -0
- data/.github/workflows/rake.yml +4 -0
- data/.github/workflows/release.yml +5 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +119 -183
- data/CLAUDE.md +78 -0
- data/Gemfile +8 -4
- data/README.adoc +2 -0
- data/Rakefile +3 -3
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +45 -0
- data/docs/_advanced/index.adoc +15 -0
- data/docs/_advanced/schema.adoc +112 -0
- data/docs/_advanced/step-map.adoc +66 -0
- data/docs/_advanced/steps.adoc +88 -0
- data/docs/_advanced/test-builder.adoc +61 -0
- data/docs/_advanced/transform.adoc +92 -0
- data/docs/_config.yml +174 -0
- data/docs/_features/html-input.adoc +69 -0
- data/docs/_features/html-output.adoc +45 -0
- data/docs/_features/index.adoc +15 -0
- data/docs/_features/marks.adoc +86 -0
- data/docs/_features/node-types.adoc +124 -0
- data/docs/_features/user-mentions.adoc +47 -0
- data/docs/_guides/custom-nodes.adoc +107 -0
- data/docs/_guides/index.adoc +13 -0
- data/docs/_guides/round-trip-html.adoc +91 -0
- data/docs/_guides/serialization.adoc +109 -0
- data/docs/_pages/index.adoc +67 -0
- data/docs/_reference/document-api.adoc +49 -0
- data/docs/_reference/index.adoc +14 -0
- data/docs/_reference/node-api.adoc +79 -0
- data/docs/_reference/schema-api.adoc +95 -0
- data/docs/_reference/transform-api.adoc +77 -0
- data/docs/_understanding/document-model.adoc +65 -0
- data/docs/_understanding/fragment.adoc +52 -0
- data/docs/_understanding/index.adoc +14 -0
- data/docs/_understanding/resolved-position.adoc +53 -0
- data/docs/_understanding/slice.adoc +54 -0
- data/docs/lychee.toml +63 -0
- data/lib/prosereflect/attribute/base.rb +4 -6
- data/lib/prosereflect/attribute/bold.rb +2 -4
- data/lib/prosereflect/attribute/href.rb +1 -3
- data/lib/prosereflect/attribute/id.rb +7 -7
- data/lib/prosereflect/attribute.rb +4 -7
- data/lib/prosereflect/blockquote.rb +19 -11
- data/lib/prosereflect/bullet_list.rb +36 -29
- data/lib/prosereflect/code_block.rb +23 -27
- data/lib/prosereflect/code_block_wrapper.rb +12 -13
- data/lib/prosereflect/document.rb +14 -22
- data/lib/prosereflect/fragment.rb +249 -0
- data/lib/prosereflect/hard_break.rb +6 -6
- data/lib/prosereflect/heading.rb +14 -15
- data/lib/prosereflect/horizontal_rule.rb +23 -14
- data/lib/prosereflect/image.rb +32 -23
- data/lib/prosereflect/input/html.rb +179 -104
- data/lib/prosereflect/input.rb +7 -0
- data/lib/prosereflect/list_item.rb +11 -12
- data/lib/prosereflect/mark/base.rb +9 -11
- data/lib/prosereflect/mark/bold.rb +1 -3
- data/lib/prosereflect/mark/code.rb +1 -3
- data/lib/prosereflect/mark/italic.rb +1 -3
- data/lib/prosereflect/mark/link.rb +1 -3
- data/lib/prosereflect/mark/strike.rb +1 -3
- data/lib/prosereflect/mark/subscript.rb +1 -3
- data/lib/prosereflect/mark/superscript.rb +1 -3
- data/lib/prosereflect/mark/underline.rb +1 -3
- data/lib/prosereflect/mark.rb +9 -5
- data/lib/prosereflect/node.rb +171 -33
- data/lib/prosereflect/ordered_list.rb +17 -14
- data/lib/prosereflect/output/html.rb +279 -50
- data/lib/prosereflect/output.rb +7 -0
- data/lib/prosereflect/paragraph.rb +11 -13
- data/lib/prosereflect/parser.rb +56 -66
- data/lib/prosereflect/resolved_pos.rb +256 -0
- data/lib/prosereflect/schema/attribute.rb +57 -0
- data/lib/prosereflect/schema/content_match.rb +656 -0
- data/lib/prosereflect/schema/fragment.rb +166 -0
- data/lib/prosereflect/schema/mark.rb +121 -0
- data/lib/prosereflect/schema/mark_type.rb +130 -0
- data/lib/prosereflect/schema/node.rb +236 -0
- data/lib/prosereflect/schema/node_type.rb +274 -0
- data/lib/prosereflect/schema/schema_main.rb +190 -0
- data/lib/prosereflect/schema/spec.rb +92 -0
- data/lib/prosereflect/schema.rb +39 -0
- data/lib/prosereflect/table.rb +12 -13
- data/lib/prosereflect/table_cell.rb +13 -13
- data/lib/prosereflect/table_header.rb +17 -17
- data/lib/prosereflect/table_row.rb +12 -12
- data/lib/prosereflect/text.rb +35 -11
- data/lib/prosereflect/transform/attr_step.rb +157 -0
- data/lib/prosereflect/transform/insert_step.rb +115 -0
- data/lib/prosereflect/transform/mapping.rb +82 -0
- data/lib/prosereflect/transform/mark_step.rb +269 -0
- data/lib/prosereflect/transform/replace_around_step.rb +181 -0
- data/lib/prosereflect/transform/replace_step.rb +157 -0
- data/lib/prosereflect/transform/slice.rb +91 -0
- data/lib/prosereflect/transform/step.rb +89 -0
- data/lib/prosereflect/transform/step_map.rb +126 -0
- data/lib/prosereflect/transform/structure.rb +120 -0
- data/lib/prosereflect/transform/transform.rb +341 -0
- data/lib/prosereflect/transform.rb +26 -0
- data/lib/prosereflect/user.rb +15 -15
- data/lib/prosereflect/version.rb +1 -1
- data/lib/prosereflect.rb +30 -17
- data/prosereflect.gemspec +17 -16
- data/spec/fixtures/documents/formatted_text.yaml +14 -0
- data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
- data/spec/fixtures/documents/lists_doc.yaml +32 -0
- data/spec/fixtures/documents/mixed_content.yaml +40 -0
- data/spec/fixtures/documents/nested_doc.yaml +20 -0
- data/spec/fixtures/documents/simple_doc.yaml +6 -0
- data/spec/fixtures/documents/table_doc.yaml +32 -0
- data/spec/fixtures/documents/transform_test.yaml +14 -0
- data/spec/fixtures/schema/custom_schema.rb +37 -0
- data/spec/fixtures/schema/test_schema.rb +46 -0
- data/spec/fixtures/test_builder/helpers.rb +212 -0
- data/spec/prosereflect/document_spec.rb +332 -330
- data/spec/prosereflect/fragment_spec.rb +273 -0
- data/spec/prosereflect/hard_break_spec.rb +125 -125
- data/spec/prosereflect/input/html_spec.rb +718 -522
- data/spec/prosereflect/node_spec.rb +311 -182
- data/spec/prosereflect/output/html_spec.rb +105 -105
- data/spec/prosereflect/output/whitespace_spec.rb +248 -0
- data/spec/prosereflect/paragraph_spec.rb +275 -274
- data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
- data/spec/prosereflect/parser_spec.rb +185 -180
- data/spec/prosereflect/resolved_pos_spec.rb +74 -0
- data/spec/prosereflect/schema/conftest.rb +68 -0
- data/spec/prosereflect/schema/content_match_spec.rb +237 -0
- data/spec/prosereflect/schema/mark_spec.rb +274 -0
- data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
- data/spec/prosereflect/schema/node_type_spec.rb +142 -0
- data/spec/prosereflect/schema/schema_spec.rb +194 -0
- data/spec/prosereflect/table_cell_spec.rb +183 -183
- data/spec/prosereflect/table_row_spec.rb +149 -149
- data/spec/prosereflect/table_spec.rb +320 -318
- data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
- data/spec/prosereflect/text_spec.rb +133 -132
- data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
- data/spec/prosereflect/transform/mapping_spec.rb +226 -0
- data/spec/prosereflect/transform/replace_spec.rb +832 -0
- data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
- data/spec/prosereflect/transform/slice_spec.rb +48 -0
- data/spec/prosereflect/transform/step_map_spec.rb +70 -0
- data/spec/prosereflect/transform/step_spec.rb +211 -0
- data/spec/prosereflect/transform/structure_spec.rb +98 -0
- data/spec/prosereflect/transform/transform_spec.rb +238 -0
- data/spec/prosereflect/user_spec.rb +31 -28
- data/spec/prosereflect_spec.rb +28 -26
- data/spec/spec_helper.rb +7 -6
- data/spec/support/matchers.rb +6 -6
- data/spec/support/shared_examples.rb +49 -49
- metadata +96 -5
- data/spec/prosereflect/version_spec.rb +0 -11
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step_map"
|
|
4
|
+
require_relative "mapping"
|
|
5
|
+
|
|
6
|
+
module Prosereflect
|
|
7
|
+
module Transform
|
|
8
|
+
# Base class for all document transformations.
|
|
9
|
+
# A step represents an atomic document change.
|
|
10
|
+
class Step
|
|
11
|
+
# Apply this step to a document
|
|
12
|
+
# Returns a Result with the new document or an error
|
|
13
|
+
def apply(_doc)
|
|
14
|
+
raise NotImplementedError, "#{self.class} must implement #apply"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get the step map for position tracking
|
|
18
|
+
def get_map
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #get_map"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Merge this step with another if possible
|
|
23
|
+
# Returns a new step or nil if not mergeable
|
|
24
|
+
def merge(_other)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return an inverted step that undoes this one
|
|
29
|
+
# Takes the document as input to compute the inverse
|
|
30
|
+
def invert(_doc)
|
|
31
|
+
raise NotImplementedError, "#{self.class} must implement #invert"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get a JSON representation
|
|
35
|
+
def to_json(*_args)
|
|
36
|
+
{
|
|
37
|
+
"stepType" => step_type,
|
|
38
|
+
"pos" => pos,
|
|
39
|
+
"to" => to,
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create a step from JSON
|
|
44
|
+
def self.from_json(_schema, _json)
|
|
45
|
+
raise NotImplementedError, "#{self.class} must implement #from_json"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The type name of this step
|
|
49
|
+
def step_type
|
|
50
|
+
raise NotImplementedError, "#{self.class} must implement #step_type"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Position where this step applies
|
|
54
|
+
def pos
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# End position (for range steps)
|
|
59
|
+
def to
|
|
60
|
+
pos
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Result of applying a step
|
|
64
|
+
class Result
|
|
65
|
+
attr_reader :doc, :failed
|
|
66
|
+
|
|
67
|
+
def initialize(doc: nil, failed: nil)
|
|
68
|
+
@doc = doc
|
|
69
|
+
@failed = failed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if the step was successfully applied
|
|
73
|
+
def ok?
|
|
74
|
+
!@failed && @doc
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create a successful result
|
|
78
|
+
def self.ok(doc)
|
|
79
|
+
new(doc: doc)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create a failed result
|
|
83
|
+
def self.fail(reason)
|
|
84
|
+
new(failed: reason)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosereflect
|
|
4
|
+
module Transform
|
|
5
|
+
# Maps positions through a step.
|
|
6
|
+
# Represents how positions change when a step is applied.
|
|
7
|
+
class StepMap
|
|
8
|
+
attr_reader :ranges # Array of [old_start, old_end, new_start, new_end]
|
|
9
|
+
|
|
10
|
+
def initialize(ranges = [])
|
|
11
|
+
@ranges = ranges
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Map a position through this step map
|
|
15
|
+
# Returns the new position
|
|
16
|
+
def map(pos)
|
|
17
|
+
offset = 0
|
|
18
|
+
@ranges.each do |old_start, old_end, new_start, new_end|
|
|
19
|
+
if pos <= old_start
|
|
20
|
+
return pos + (new_start - old_start)
|
|
21
|
+
elsif pos < old_end
|
|
22
|
+
return new_start + (pos - old_start)
|
|
23
|
+
elsif pos >= old_end
|
|
24
|
+
offset += (new_end - old_end)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
pos + offset
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Map a position, returning result with deletion information
|
|
31
|
+
def map_result(pos, on_del: nil) # rubocop:disable Lint:UnusedMethodArgument
|
|
32
|
+
new_pos = map(pos)
|
|
33
|
+
deleted = deleted?(pos)
|
|
34
|
+
Result.new(pos: new_pos, deleted: deleted, transformed: new_pos != pos)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if a position was deleted by this step
|
|
38
|
+
def deleted?(pos)
|
|
39
|
+
@ranges.any? do |old_start, old_end, _new_start, _new_end|
|
|
40
|
+
pos >= old_start && pos < old_end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Add another map to this one (composition)
|
|
45
|
+
def add_map(other)
|
|
46
|
+
return StepMap.new(other.ranges.dup) if @ranges.empty?
|
|
47
|
+
return StepMap.new(@ranges.dup) if other.ranges.empty?
|
|
48
|
+
|
|
49
|
+
StepMap.new(merge_ranges_arrays(@ranges.dup, other.ranges.dup))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def merge_ranges_arrays(ranges1, ranges2)
|
|
53
|
+
return ranges1 if ranges2.empty?
|
|
54
|
+
return ranges2 if ranges1.empty?
|
|
55
|
+
|
|
56
|
+
head1 = ranges1.first
|
|
57
|
+
head2 = ranges2.first
|
|
58
|
+
merged_head = compute_merged_head(head1, head2, ranges1, ranges2)
|
|
59
|
+
tail = compute_tail(head1, head2, ranges1, ranges2)
|
|
60
|
+
[merged_head] + tail
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def compute_merged_head(head1, head2, _ranges1, _ranges2)
|
|
64
|
+
if head1[1] <= head2[0]
|
|
65
|
+
head1
|
|
66
|
+
elsif head2[1] <= head1[0]
|
|
67
|
+
head2
|
|
68
|
+
else
|
|
69
|
+
merge_ranges(head1, head2)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compute_tail(head1, head2, ranges1, ranges2)
|
|
74
|
+
if head1[1] <= head2[0]
|
|
75
|
+
merge_ranges_arrays(ranges1[1..], ranges2)
|
|
76
|
+
elsif head2[1] <= head1[0]
|
|
77
|
+
merge_ranges_arrays(ranges1, ranges2[1..])
|
|
78
|
+
else
|
|
79
|
+
merge_ranges_arrays(ranges1[1..], ranges2[1..])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Create an empty step map
|
|
84
|
+
def self.empty
|
|
85
|
+
new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create a step map for a single deletion
|
|
89
|
+
def self.delete(from, to)
|
|
90
|
+
new([[from, to, from, from]])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Create a step map for a single replacement
|
|
94
|
+
def self.replace(from, to, target_from, target_to)
|
|
95
|
+
delta = target_to - target_from
|
|
96
|
+
new([[from, to, target_from, target_from + delta]])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_s
|
|
100
|
+
"<StepMap #{@ranges.inspect}>"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect
|
|
104
|
+
to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Result of mapping a position
|
|
108
|
+
Result = Struct.new(:pos, :deleted, :transformed, keyword_init: true) do
|
|
109
|
+
def initialize(pos: 0, deleted: false, transformed: false)
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def merge_ranges(range1, range2)
|
|
117
|
+
[
|
|
118
|
+
[range1[0], range2[0]].min,
|
|
119
|
+
[range1[1], range2[1]].max,
|
|
120
|
+
[range1[2], range2[2]].min,
|
|
121
|
+
[range1[3], range2[3]].max,
|
|
122
|
+
]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step"
|
|
4
|
+
require_relative "replace_step"
|
|
5
|
+
|
|
6
|
+
module Prosereflect
|
|
7
|
+
module Transform
|
|
8
|
+
# Structure predicates and helpers for document manipulation
|
|
9
|
+
class Structure
|
|
10
|
+
class << self
|
|
11
|
+
# Check if we can split at a position
|
|
12
|
+
# Returns true if the position allows a split (e.g., inside a text node)
|
|
13
|
+
def can_split?(doc, pos, types = nil)
|
|
14
|
+
return false if pos.negative? || pos > doc.node_size
|
|
15
|
+
|
|
16
|
+
resolved = doc.resolve(pos)
|
|
17
|
+
parent = resolved.parent
|
|
18
|
+
|
|
19
|
+
return false unless parent.respond_to?(:content)
|
|
20
|
+
|
|
21
|
+
node_type = parent.respond_to?(:type) ? parent.type : nil
|
|
22
|
+
|
|
23
|
+
if types
|
|
24
|
+
types.all? { |t| t.respond_to?(:name) ? t.name == node_type : t == node_type }
|
|
25
|
+
else
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Find the target depth for lifting content out of a wrapper
|
|
31
|
+
# Returns the depth to which content should be lifted
|
|
32
|
+
def lift_target(fragment, from, to)
|
|
33
|
+
depth = 0
|
|
34
|
+
pos = 0
|
|
35
|
+
|
|
36
|
+
fragment.content.each do |node|
|
|
37
|
+
node_end = pos + node.node_size
|
|
38
|
+
break if pos >= to
|
|
39
|
+
|
|
40
|
+
# This node is within the range being lifted
|
|
41
|
+
if pos < to && node_end > from && node.respond_to?(:defining?) && node.defining?
|
|
42
|
+
depth += 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
pos = node_end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
depth
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Find wrapper nodes needed to wrap a range
|
|
52
|
+
# Returns array of node types that would wrap the range
|
|
53
|
+
def find_wrapping(fragment, _from, to, node_type, attrs = nil)
|
|
54
|
+
wrappers = []
|
|
55
|
+
current_depth = 0
|
|
56
|
+
pos = 0
|
|
57
|
+
|
|
58
|
+
fragment.content.each do |node|
|
|
59
|
+
node_end = pos + node.node_size
|
|
60
|
+
break if pos >= to
|
|
61
|
+
|
|
62
|
+
if node.respond_to?(:defining?) && node.defining? && current_depth.zero?
|
|
63
|
+
# Found a defining node at the boundary
|
|
64
|
+
wrappers << build_wrapper(node_type, attrs)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
pos = node_end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
wrappers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if nodes can be joined at a position
|
|
74
|
+
def can_join?(doc, pos)
|
|
75
|
+
return false if pos <= 0 || pos >= doc.node_size
|
|
76
|
+
|
|
77
|
+
resolved = doc.resolve(pos)
|
|
78
|
+
|
|
79
|
+
# We need to be at a boundary between two children
|
|
80
|
+
# Check the node at the depth where we're at a child boundary
|
|
81
|
+
depth = resolved.depth
|
|
82
|
+
return false unless depth.positive?
|
|
83
|
+
|
|
84
|
+
parent = resolved.node(depth - 1)
|
|
85
|
+
return false unless parent.respond_to?(:content)
|
|
86
|
+
return false if parent.content.nil?
|
|
87
|
+
|
|
88
|
+
index = resolved.index(depth)
|
|
89
|
+
return false unless index.positive? && index < parent.content.size
|
|
90
|
+
|
|
91
|
+
prev_node = parent.content[index - 1]
|
|
92
|
+
next_node = parent.content[index]
|
|
93
|
+
|
|
94
|
+
prev_node.type == next_node.type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Find positions where a join can happen
|
|
98
|
+
def join_point?(doc, pos)
|
|
99
|
+
return false unless can_join?(doc, pos)
|
|
100
|
+
|
|
101
|
+
pos
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def build_wrapper(node_type, attrs)
|
|
107
|
+
if node_type.is_a?(String)
|
|
108
|
+
Prosereflect::Node.from_h(
|
|
109
|
+
"type" => node_type,
|
|
110
|
+
"attrs" => attrs || {},
|
|
111
|
+
"content" => [],
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
node_type
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step"
|
|
4
|
+
require_relative "step_map"
|
|
5
|
+
require_relative "mapping"
|
|
6
|
+
require_relative "slice"
|
|
7
|
+
require_relative "replace_step"
|
|
8
|
+
require_relative "replace_around_step"
|
|
9
|
+
require_relative "mark_step"
|
|
10
|
+
require_relative "attr_step"
|
|
11
|
+
require_relative "insert_step"
|
|
12
|
+
|
|
13
|
+
module Prosereflect
|
|
14
|
+
module Transform
|
|
15
|
+
# A chainable document transformation.
|
|
16
|
+
# Accumulates steps and their mappings.
|
|
17
|
+
class Transform
|
|
18
|
+
attr_reader :steps, :mapping
|
|
19
|
+
|
|
20
|
+
def initialize(doc)
|
|
21
|
+
@doc = doc
|
|
22
|
+
@steps = []
|
|
23
|
+
@mapping = Mapping.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Add a mark to all content in range
|
|
27
|
+
def add_mark(from, to, mark)
|
|
28
|
+
add_step(AddMarkStep.new(from, to, mark))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Remove a mark from all content in range
|
|
32
|
+
def remove_mark(from, to, mark)
|
|
33
|
+
add_step(RemoveMarkStep.new(from, to, mark))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Insert content at position
|
|
37
|
+
def insert(pos, content)
|
|
38
|
+
add_step(InsertStep.new(pos, content))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Delete content in range
|
|
42
|
+
def delete(from, to)
|
|
43
|
+
add_step(DeleteStep.new(from, to))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Replace content in range with slice
|
|
47
|
+
def replace(from, to, slice = Slice.empty)
|
|
48
|
+
add_step(ReplaceStep.new(from, to, slice))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Replace content with specific nodes
|
|
52
|
+
def replace_with(from, to, *nodes)
|
|
53
|
+
content = Fragment.new(nodes.flatten)
|
|
54
|
+
slice = Slice.new(content)
|
|
55
|
+
add_step(ReplaceStep.new(from, to, slice))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Set attribute on node at position
|
|
59
|
+
def set_node_attribute(pos, attrs)
|
|
60
|
+
add_step(AttrStep.new(pos, attrs))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Set document attribute
|
|
64
|
+
def set_doc_attribute(attrs)
|
|
65
|
+
add_step(DocAttrStep.new(attrs))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Apply all accumulated steps to the document
|
|
69
|
+
# Returns self for chaining
|
|
70
|
+
def apply
|
|
71
|
+
@steps.each do |step|
|
|
72
|
+
result = step.apply(@doc)
|
|
73
|
+
raise ApplyError, "Step #{step.class} failed: #{result.failed}" unless result.ok?
|
|
74
|
+
|
|
75
|
+
@doc = result.doc
|
|
76
|
+
end
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Apply and return the transformed document
|
|
81
|
+
def doc
|
|
82
|
+
# Apply pending steps
|
|
83
|
+
apply if @steps.any?
|
|
84
|
+
@doc
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if any steps have been applied
|
|
88
|
+
def empty?
|
|
89
|
+
@steps.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get the number of steps
|
|
93
|
+
def size
|
|
94
|
+
@steps.length
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Add a step and track its mapping
|
|
98
|
+
def add_step(step)
|
|
99
|
+
@steps << step
|
|
100
|
+
@mapping.add_map(step.get_map)
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get the mapping for all applied steps
|
|
105
|
+
def maps
|
|
106
|
+
@mapping.to_a
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Create a new transform with the same document
|
|
110
|
+
def clone
|
|
111
|
+
Transform.new(@doc)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Roll back the last step
|
|
115
|
+
def rollback
|
|
116
|
+
return self if @steps.empty?
|
|
117
|
+
|
|
118
|
+
step = @steps.pop
|
|
119
|
+
@mapping = Mapping.new(maps: @mapping.to_a[0...-1])
|
|
120
|
+
|
|
121
|
+
inverted = step.invert(@doc)
|
|
122
|
+
result = inverted.apply(@doc)
|
|
123
|
+
if result.ok?
|
|
124
|
+
@doc = result.doc
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if we can step forward
|
|
131
|
+
def can_apply?
|
|
132
|
+
@steps.all? { |step| step.apply(@doc).ok? }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Add mark using schema
|
|
136
|
+
def add_mark_by_type(from, to, mark_type_name, schema, attrs = nil)
|
|
137
|
+
mark_type = schema.mark_type(mark_type_name)
|
|
138
|
+
mark = mark_type.create(attrs)
|
|
139
|
+
add_mark(from, to, mark)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Remove mark using schema
|
|
143
|
+
def remove_mark_by_type(from, to, mark_type_name, schema)
|
|
144
|
+
mark_type = schema.mark_type(mark_type_name)
|
|
145
|
+
mark = mark_type.create
|
|
146
|
+
remove_mark(from, to, mark)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Lift content out of a wrapper node
|
|
150
|
+
# range: NodeRange representing the content to lift
|
|
151
|
+
# target: depth to lift to
|
|
152
|
+
def lift(range, target)
|
|
153
|
+
from_ = range.from
|
|
154
|
+
to_ = range.to
|
|
155
|
+
depth = range.depth
|
|
156
|
+
|
|
157
|
+
gap_start = position_before_depth(from_, depth + 1)
|
|
158
|
+
gap_end = position_after_depth(to_, depth + 1)
|
|
159
|
+
start = gap_start
|
|
160
|
+
end_pos = gap_end
|
|
161
|
+
|
|
162
|
+
before = Fragment.empty
|
|
163
|
+
open_start = 0
|
|
164
|
+
d = depth
|
|
165
|
+
splitting = false
|
|
166
|
+
while d > target
|
|
167
|
+
if splitting || node_index_at_depth(from_, d).positive?
|
|
168
|
+
splitting = true
|
|
169
|
+
before = Fragment.from(node_at_depth(from_, d).copy(before))
|
|
170
|
+
open_start += 1
|
|
171
|
+
else
|
|
172
|
+
start -= 1
|
|
173
|
+
end
|
|
174
|
+
d -= 1
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
after = Fragment.empty
|
|
178
|
+
open_end = 0
|
|
179
|
+
d = depth
|
|
180
|
+
splitting = false
|
|
181
|
+
while d > target
|
|
182
|
+
if splitting || position_after_depth(to_, d + 1) < node_end_at_depth(to_, d)
|
|
183
|
+
splitting = true
|
|
184
|
+
after = Fragment.from(node_at_depth(to_, d).copy(after))
|
|
185
|
+
open_end += 1
|
|
186
|
+
else
|
|
187
|
+
end_pos += 1
|
|
188
|
+
end
|
|
189
|
+
d -= 1
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
replace_around_step(
|
|
193
|
+
start,
|
|
194
|
+
end_pos,
|
|
195
|
+
gap_start,
|
|
196
|
+
gap_end,
|
|
197
|
+
Slice.new(before.append(after), open_start, open_end),
|
|
198
|
+
before.size - open_start,
|
|
199
|
+
true,
|
|
200
|
+
)
|
|
201
|
+
self
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Wrap content in nodes
|
|
205
|
+
# range: NodeRange representing the content to wrap
|
|
206
|
+
# wrappers: array of NodeTypeWithAttrs representing wrapper nodes
|
|
207
|
+
def wrap(range, wrappers)
|
|
208
|
+
content = Fragment.empty
|
|
209
|
+
i = wrappers.length - 1
|
|
210
|
+
while i >= 0
|
|
211
|
+
if content.size.positive?
|
|
212
|
+
match = wrappers[i].type.content_match.match_fragment(content)
|
|
213
|
+
unless match&.valid_end
|
|
214
|
+
raise TransformError, "Wrapper type given to Transform.wrap does not form valid content of its parent wrapper"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
content = Fragment.from(
|
|
218
|
+
wrappers[i].type.create(wrappers[i].attrs, content),
|
|
219
|
+
)
|
|
220
|
+
i -= 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
start = range.start
|
|
224
|
+
end_pos = range.end
|
|
225
|
+
replace_around_step(
|
|
226
|
+
start,
|
|
227
|
+
end_pos,
|
|
228
|
+
start,
|
|
229
|
+
end_pos,
|
|
230
|
+
Slice.new(content, 0, 0),
|
|
231
|
+
wrappers.length,
|
|
232
|
+
true,
|
|
233
|
+
)
|
|
234
|
+
self
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Split a node at a position
|
|
238
|
+
def split(pos, depth = 1)
|
|
239
|
+
resolved = @doc.resolve(pos)
|
|
240
|
+
before = Fragment.empty
|
|
241
|
+
open_start = 0
|
|
242
|
+
open_end = 0
|
|
243
|
+
|
|
244
|
+
d = depth
|
|
245
|
+
while d.positive?
|
|
246
|
+
node = resolved.node(d)
|
|
247
|
+
if d == depth
|
|
248
|
+
before = Fragment.from(node.copy(Fragment.empty))
|
|
249
|
+
open_start = node.is_a?(Prosereflect::Node) && node.content&.size.to_i.positive? ? 1 : 0
|
|
250
|
+
open_end = 0
|
|
251
|
+
else
|
|
252
|
+
before = Fragment.from(node.copy(before))
|
|
253
|
+
open_start += 1
|
|
254
|
+
end
|
|
255
|
+
d -= 1
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
step = ReplaceAroundStep.new(
|
|
259
|
+
pos,
|
|
260
|
+
pos,
|
|
261
|
+
pos,
|
|
262
|
+
pos,
|
|
263
|
+
Slice.new(before, open_start, open_end),
|
|
264
|
+
open_start,
|
|
265
|
+
structure: false,
|
|
266
|
+
)
|
|
267
|
+
add_step(step)
|
|
268
|
+
self
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Join two nodes at a position
|
|
272
|
+
def join(pos, depth = 1)
|
|
273
|
+
resolved = @doc.resolve(pos)
|
|
274
|
+
d = depth
|
|
275
|
+
while d.positive?
|
|
276
|
+
# Check if we can join at this depth
|
|
277
|
+
parent = resolved.node(d - 1) if d >= 1
|
|
278
|
+
idx = resolved.index(d)
|
|
279
|
+
|
|
280
|
+
if parent&.content && idx.positive? && idx < parent.content.size
|
|
281
|
+
before_node = parent.content[idx - 1]
|
|
282
|
+
after_node = parent.content[idx]
|
|
283
|
+
|
|
284
|
+
if before_node.type == after_node.type
|
|
285
|
+
# Join point is at the boundary between before_node and after_node
|
|
286
|
+
join_from = resolved.start(d) - before_node.node_size
|
|
287
|
+
join_to = resolved.start(d) + after_node.node_size
|
|
288
|
+
replace(join_from, join_to, Slice.empty)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
d -= 1
|
|
292
|
+
end
|
|
293
|
+
self
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
class ApplyError < StandardError; end
|
|
297
|
+
class TransformError < StandardError; end
|
|
298
|
+
|
|
299
|
+
def to_s
|
|
300
|
+
"<Transform steps=#{@steps.length}>"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def inspect
|
|
304
|
+
to_s
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def replace_around_step(from, to, gap_from, gap_to, slice, insert, structure)
|
|
310
|
+
add_step(ReplaceAroundStep.new(from, to, gap_from, gap_to, slice, insert, structure: structure))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def position_before_depth(pos, depth)
|
|
314
|
+
resolved = @doc.resolve(pos)
|
|
315
|
+
node = resolved.node(depth)
|
|
316
|
+
pos - (node.is_a?(Prosereflect::Node) ? 1 : 0)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def position_after_depth(pos, depth)
|
|
320
|
+
resolved = @doc.resolve(pos)
|
|
321
|
+
node = resolved.node(depth)
|
|
322
|
+
pos + (node.is_a?(Prosereflect::Node) ? 1 : 0)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def node_index_at_depth(pos, depth)
|
|
326
|
+
resolved = @doc.resolve(pos)
|
|
327
|
+
resolved.index(depth)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def node_at_depth(pos, depth)
|
|
331
|
+
resolved = @doc.resolve(pos)
|
|
332
|
+
resolved.node(depth)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def node_end_at_depth(pos, depth)
|
|
336
|
+
resolved = @doc.resolve(pos)
|
|
337
|
+
resolved.end(depth)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transform/step_map"
|
|
4
|
+
require_relative "transform/mapping"
|
|
5
|
+
require_relative "transform/slice"
|
|
6
|
+
require_relative "transform/step"
|
|
7
|
+
require_relative "transform/replace_step"
|
|
8
|
+
require_relative "transform/mark_step"
|
|
9
|
+
require_relative "transform/attr_step"
|
|
10
|
+
require_relative "transform/insert_step"
|
|
11
|
+
require_relative "transform/structure"
|
|
12
|
+
require_relative "transform/transform"
|
|
13
|
+
|
|
14
|
+
module Prosereflect
|
|
15
|
+
# Transform system for chainable document transformations
|
|
16
|
+
# Based on ProseMirror's transform/step system
|
|
17
|
+
module Transform
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Raised when a step cannot be applied to a document
|
|
21
|
+
class ApplyError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when a step cannot be inverted
|
|
24
|
+
class InvertError < Error; end
|
|
25
|
+
end
|
|
26
|
+
end
|