Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-07-06 21:10:31 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-07-06 21:10:31 +0300
commiteec8ec6e4ed7aea652141681749f82ca63e9fbf8 (patch)
treeb643c4fa7894834830f2bc52f0005f8b7789dd14 /gems/ipynbdiff
parent6d3676d61064af469f2fa1171bec4575235c6739 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'gems/ipynbdiff')
-rw-r--r--gems/ipynbdiff/.gitignore3
-rw-r--r--gems/ipynbdiff/.gitlab-ci.yml4
-rw-r--r--gems/ipynbdiff/.rubocop.yml11
-rw-r--r--gems/ipynbdiff/Gemfile5
-rw-r--r--gems/ipynbdiff/Gemfile.lock75
-rw-r--r--gems/ipynbdiff/LICENSE21
-rw-r--r--gems/ipynbdiff/README.md56
-rw-r--r--gems/ipynbdiff/ipynbdiff.gemspec32
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff.rb24
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/diff.rb20
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb73
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb109
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb25
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb20
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/transformer.rb111
-rw-r--r--gems/ipynbdiff/lib/ipynb_diff/version.rb7
-rw-r--r--gems/ipynbdiff/spec/benchmark.rb68
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb55
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb94
-rw-r--r--gems/ipynbdiff/spec/ipynb_diff_spec.rb129
-rw-r--r--gems/ipynbdiff/spec/test_helper.rb46
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md7
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt7
-rw-r--r--gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/expected.md16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt16
-rw-r--r--gems/ipynbdiff/spec/testdata/error_output/input.ipynb32
-rw-r--r--gems/ipynbdiff/spec/testdata/from.ipynb197
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/expected.md10
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt10
-rw-r--r--gems/ipynbdiff/spec/testdata/hide_images/input.ipynb45
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md11
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt11
-rw-r--r--gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb74
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/expected.md10
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt10
-rw-r--r--gems/ipynbdiff/spec/testdata/latex_output/input.ipynb34
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/expected.md19
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt19
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md0
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt0
-rw-r--r--gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb25
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md13
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt13
-rw-r--r--gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb29
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/expected.md7
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt7
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code/input.ipynb21
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb12
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb14
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb11
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/only_md/input.ipynb21
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/expected.md4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt4
-rw-r--r--gems/ipynbdiff/spec/testdata/only_raw/input.ipynb15
-rw-r--r--gems/ipynbdiff/spec/testdata/percent_decorator/expected.md68
-rw-r--r--gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt68
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/expected.md3
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt3
-rw-r--r--gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb17
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb11
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/stream_text/input.ipynb27
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/expected.md17
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt17
-rw-r--r--gems/ipynbdiff/spec/testdata/svg/input.ipynb66
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/expected.md9
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt9
-rw-r--r--gems/ipynbdiff/spec/testdata/text_output/input.ipynb31
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected.md14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt14
-rw-r--r--gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb49
-rw-r--r--gems/ipynbdiff/spec/testdata/to.ipynb200
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md5
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt5
-rw-r--r--gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb27
92 files changed, 2532 insertions, 0 deletions
diff --git a/gems/ipynbdiff/.gitignore b/gems/ipynbdiff/.gitignore
new file mode 100644
index 00000000000..fae8d1c772f
--- /dev/null
+++ b/gems/ipynbdiff/.gitignore
@@ -0,0 +1,3 @@
+*.gem
+coverage
+.bundle
diff --git a/gems/ipynbdiff/.gitlab-ci.yml b/gems/ipynbdiff/.gitlab-ci.yml
new file mode 100644
index 00000000000..de5c989a4a4
--- /dev/null
+++ b/gems/ipynbdiff/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "ipynbdiff"
diff --git a/gems/ipynbdiff/.rubocop.yml b/gems/ipynbdiff/.rubocop.yml
new file mode 100644
index 00000000000..f3a696778a4
--- /dev/null
+++ b/gems/ipynbdiff/.rubocop.yml
@@ -0,0 +1,11 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+CodeReuse/ActiveRecord:
+ Enabled: false
+
+Naming/FileName:
+ Exclude:
+ - spec/**/*.rb
+ - lib/gitlab/rspec.rb
+ - lib/gitlab/rspec/all.rb
diff --git a/gems/ipynbdiff/Gemfile b/gems/ipynbdiff/Gemfile
new file mode 100644
index 00000000000..7f4f5e950d1
--- /dev/null
+++ b/gems/ipynbdiff/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gemspec
diff --git a/gems/ipynbdiff/Gemfile.lock b/gems/ipynbdiff/Gemfile.lock
new file mode 100644
index 00000000000..1c52ff8c829
--- /dev/null
+++ b/gems/ipynbdiff/Gemfile.lock
@@ -0,0 +1,75 @@
+PATH
+ remote: .
+ specs:
+ gitlab-ipynbdiff (0.4.7)
+ diffy (~> 3.4)
+ oj (~> 3.13.16)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ ast (2.4.2)
+ benchmark-memory (0.2.0)
+ memory_profiler (~> 1)
+ binding_ninja (0.2.3)
+ coderay (1.1.3)
+ diff-lcs (1.5.0)
+ diffy (3.4.2)
+ docile (1.4.0)
+ memory_profiler (1.0.0)
+ method_source (1.0.0)
+ oj (3.13.23)
+ parser (3.1.2.0)
+ ast (~> 2.4.1)
+ proc_to_ast (0.1.0)
+ coderay
+ parser
+ unparser
+ pry (0.14.1)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ rake (13.0.6)
+ rspec (3.11.0)
+ rspec-core (~> 3.11.0)
+ rspec-expectations (~> 3.11.0)
+ rspec-mocks (~> 3.11.0)
+ rspec-core (3.11.0)
+ rspec-support (~> 3.11.0)
+ rspec-expectations (3.11.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.11.0)
+ rspec-mocks (3.11.1)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.11.0)
+ rspec-parameterized (0.5.2)
+ binding_ninja (>= 0.2.3)
+ parser
+ proc_to_ast
+ rspec (>= 2.13, < 4)
+ unparser
+ rspec-support (3.11.0)
+ simplecov (0.22.0)
+ docile (~> 1.1)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.12.3)
+ simplecov_json_formatter (0.1.4)
+ unparser (0.6.5)
+ diff-lcs (~> 1.3)
+ parser (>= 3.1.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ benchmark-memory (~> 0.2.0)
+ bundler (~> 2.2)
+ gitlab-ipynbdiff!
+ pry (~> 0.14)
+ rake (~> 13.0)
+ rspec (~> 3.10)
+ rspec-parameterized (~> 0.5.1)
+ simplecov
+
+BUNDLED WITH
+ 2.3.16
diff --git a/gems/ipynbdiff/LICENSE b/gems/ipynbdiff/LICENSE
new file mode 100644
index 00000000000..e6de2f90864
--- /dev/null
+++ b/gems/ipynbdiff/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016-2021 GitLab B.V.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/gems/ipynbdiff/README.md b/gems/ipynbdiff/README.md
new file mode 100644
index 00000000000..f046f678a4d
--- /dev/null
+++ b/gems/ipynbdiff/README.md
@@ -0,0 +1,56 @@
+# IpynbDiff: Better diff for Jupyter Notebooks
+
+This is a simple diff tool that cleans up Jupyter notebooks, transforming each [notebook](example/1/from.ipynb)
+into a [readable markdown file](example/1/from_html.md), keeping the output of cells, and running the
+diff after. Markdowns are generated using an opinionated Jupyter to Markdown conversion. This means
+that the entire file is readable on the diff.
+
+The result are diffs that are much easier to read:
+
+| Diff | IpynbDiff |
+| ----------------------------------- | ----------------------------------------------------- |
+| [Diff text](example/diff.txt) | [IpynbDiff text](example/ipynbdiff_percent.txt) |
+| ![Diff image](example/img/diff.png) | ![IpynbDiff image](example/img/ipynbdiff_percent.png) |
+
+This started as a port of [ipynbdiff](https://gitlab.com/gitlab-org/incubation-engineering/mlops/poc/ipynbdiff),
+but now has extended functionality although not working as git driver.
+
+## Usage
+
+### Generating diffs
+
+```ruby
+IpynbDiff.diff(from_path, to_path, options)
+```
+
+Options:
+
+```ruby
+@default_transform_options = {
+ preprocess_input: true, # Whether the input should be transformed
+ write_output_to: nil, # Pass a path to save the output to a file
+ format: :text, # These are the formats Diffy accepts https://github.com/samg/diffy
+ sources_are_files: false, # Weather to use the from/to as string or path to a file
+ raise_if_invalid_notebook: false, # Raises an error if the notebooks are invalid, otherwise returns nil
+ transform_options: @default_transform_options, # See below for transform options
+ diff_opts: {
+ include_diff_info: false # These are passed to Diffy https://github.com/samg/diffy
+ }
+}
+```
+
+### Transforming the notebooks
+
+It might be necessary to have the transformed files in addition to the diff.
+
+```ruby
+IpynbDiff.transform(notebook, options)
+```
+
+Options:
+
+```ruby
+@default_transform_options = {
+ include_frontmatter: false, # Whether to include or not the notebook metadata (kernel, language, etc)
+}
+```
diff --git a/gems/ipynbdiff/ipynbdiff.gemspec b/gems/ipynbdiff/ipynbdiff.gemspec
new file mode 100644
index 00000000000..1ec68557a05
--- /dev/null
+++ b/gems/ipynbdiff/ipynbdiff.gemspec
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+$LOAD_PATH.push File.expand_path('lib', __dir__ || '')
+
+require_relative 'lib/ipynb_diff/version'
+
+Gem::Specification.new do |s|
+ s.name = 'ipynbdiff'
+ s.version = IpynbDiff::Version::VERSION
+ s.summary = 'Human Readable diffs for Jupyter Notebooks'
+ s.description = 'Better diff for Jupyter Notebooks by first preprocessing them and removing clutter'
+ s.authors = ['Eduardo Bonet']
+ s.email = 'ebonet@gitlab.com'
+ # Specify which files should be added to the gem when it is released.
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
+ s.files = Dir['lib/**/*.rb']
+ s.require_paths = ["lib"]
+ s.required_ruby_version = ">= 3.0"
+ s.homepage = 'https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/ipynbdiff'
+ s.license = 'MIT'
+
+ s.add_runtime_dependency 'diffy', '~> 3.4'
+ s.add_runtime_dependency 'oj', '~> 3.13.16'
+
+ s.add_development_dependency 'benchmark-memory', '~>0.2.0'
+ s.add_development_dependency 'bundler', '~> 2.2'
+ s.add_development_dependency 'pry', '~> 0.14'
+ s.add_development_dependency 'rake', '~> 13.0'
+ s.add_development_dependency 'rspec', '~> 3.10'
+ s.add_development_dependency 'rspec-parameterized', '~> 0.5.1'
+ s.add_development_dependency 'simplecov', '~> 0.12.0'
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff.rb b/gems/ipynbdiff/lib/ipynb_diff.rb
new file mode 100644
index 00000000000..605ff6e4a75
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'ipynb_diff/transformer'
+require 'ipynb_diff/diff'
+require 'ipynb_diff/symbol_map'
+
+# Human Readable Jupyter Diffs
+module IpynbDiff
+ def self.diff(from, to, raise_if_invalid_nb: false, include_frontmatter: false, hide_images: false, diffy_opts: {})
+ transformer = Transformer.new(include_frontmatter: include_frontmatter, hide_images: hide_images)
+
+ Diff.new(transformer.transform(from), transformer.transform(to), diffy_opts)
+ rescue InvalidNotebookError
+ raise if raise_if_invalid_nb
+ end
+
+ def self.transform(notebook, raise_errors: false, include_frontmatter: true, hide_images: false)
+ return unless notebook
+
+ Transformer.new(include_frontmatter: include_frontmatter, hide_images: hide_images).transform(notebook).as_text
+ rescue InvalidNotebookError
+ raise if raise_errors
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/diff.rb b/gems/ipynbdiff/lib/ipynb_diff/diff.rb
new file mode 100644
index 00000000000..3554ac55d99
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/diff.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Custom differ for Jupyter Notebooks
+module IpynbDiff
+ require 'delegate'
+
+ # The result of a diff object
+ class Diff < SimpleDelegator
+ require 'diffy'
+
+ attr_reader :from, :to
+
+ def initialize(from, to, diffy_opts)
+ super(Diffy::Diff.new(from.as_text, to.as_text, **diffy_opts))
+
+ @from = from
+ @to = to
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb b/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb
new file mode 100644
index 00000000000..95dbcecf95c
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/output_transformer.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'ipynb_diff/symbolized_markdown_helper'
+
+module IpynbDiff
+ # Transforms Jupyter output data into markdown
+ class OutputTransformer
+ include SymbolizedMarkdownHelper
+
+ HIDDEN_IMAGE_OUTPUT = ' [Hidden Image Output]'
+
+ ORDERED_KEYS = {
+ 'execute_result' => %w[image/png image/svg+xml image/jpeg text/markdown text/latex text/plain],
+ 'display_data' => %w[image/png image/svg+xml image/jpeg text/markdown text/latex],
+ 'stream' => %w[text]
+ }.freeze
+
+ def initialize(hide_images = false)
+ @hide_images = hide_images
+ end
+
+ def transform(output, symbol)
+ case (output_type = output['output_type'])
+ when 'error'
+ transform_error(output['traceback'], symbol / 'traceback')
+ when 'execute_result', 'display_data'
+ transform_non_error(ORDERED_KEYS[output_type], output['data'], symbol / 'data')
+ when 'stream'
+ transform_element('text', output['text'], symbol)
+ end
+ end
+
+ def transform_error(traceback, symbol)
+ traceback.map.with_index do |t, idx|
+ t.split("\n").map do |l|
+ ___(symbol / idx, l.gsub(/\[[0-9][0-9;]*m/, '').sub("\u001B", ' ').delete("\u001B").rstrip)
+ end
+ end
+ end
+
+ def transform_non_error(accepted_keys, elements, symbol)
+ accepted_keys.filter { |key| elements.key?(key) }.map do |key|
+ transform_element(key, elements[key], symbol)
+ end
+ end
+
+ def transform_element(output_type, output_element, symbol_prefix)
+ new_symbol = symbol_prefix / output_type
+ case output_type
+ when 'image/png', 'image/jpeg'
+ transform_image("#{output_type};base64", output_element, new_symbol)
+ when 'image/svg+xml'
+ transform_image("#{output_type};utf8", output_element, new_symbol)
+ when 'text/markdown', 'text/latex', 'text/plain', 'text'
+ transform_text(output_element, new_symbol)
+ end
+ end
+
+ def transform_image(image_type, image_content, symbol)
+ return ___(nil, HIDDEN_IMAGE_OUTPUT) if @hide_images
+
+ lines = image_content.is_a?(Array) ? image_content : [image_content]
+
+ single_line = lines.map(&:strip).join.gsub(/\s+/, ' ')
+
+ ___(symbol, " ![](data:#{image_type},#{single_line})")
+ end
+
+ def transform_text(text_content, symbol)
+ symbolize_array(symbol, text_content) { |l| " #{l.rstrip}" }
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb b/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb
new file mode 100644
index 00000000000..383f1de5c18
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/symbol_map.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ require 'oj'
+
+ # Creates a map from a symbol to the line number it appears in a Json file
+ #
+ # Example:
+ #
+ # Input:
+ #
+ # 1. {
+ # 2. 'obj1': [
+ # 3. {
+ # 4. 'obj2': 5
+ # 5. },
+ # 6. 3,
+ # 7. {
+ # 8. 'obj3': {
+ # 9. 'obj4': 'b'
+ # 10. }
+ # 11. }
+ # 12. ]
+ # 13.}
+ #
+ # Output:
+ #
+ # Symbol Line Number
+ # .obj1 -> 2
+ # .obj1.0 -> 3
+ # .obj1.0 -> 3
+ # .obj1.0.obj2 -> 4
+ # .obj1.1 -> 6
+ # .obj1.2 -> 7
+ # .obj1.2.obj3 -> 8
+ # .obj1.2.obj3.obj4 -> 9
+ #
+ class SymbolMap
+ # rubocop:disable Lint/UnusedMethodArgument
+ class << self
+ def handler
+ @handler ||= SymbolMap.new
+ end
+
+ def parser
+ @parser ||= Oj::Parser.new(:saj).tap { |p| p.handler = handler }
+ end
+
+ def parse(notebook, *args)
+ handler.reset
+ parser.parse(notebook)
+ handler.symbols
+ end
+ end
+
+ attr_accessor :symbols
+
+ def hash_start(key, line, column)
+ add_symbol(key_or_index(key), line)
+ end
+
+ def hash_end(key, line, column)
+ @current_path.pop
+ end
+
+ def array_start(key, line, column)
+ @current_array_index << 0
+
+ add_symbol(key, line)
+ end
+
+ def array_end(key, line, column)
+ @current_path.pop
+ @current_array_index.pop
+ end
+
+ def add_value(value, key, line, column)
+ add_symbol(key_or_index(key), line)
+
+ @current_path.pop
+ end
+
+ def add_symbol(symbol, line)
+ @symbols[@current_path.append(symbol).join('.')] = line if symbol
+ end
+
+ def key_or_index(key)
+ if key.nil? # value in an array
+ if @current_path.empty?
+ @current_path = ['']
+ return
+ end
+
+ symbol = @current_array_index.last
+ @current_array_index[-1] += 1
+ symbol
+ else
+ key
+ end
+ end
+
+ def reset
+ @current_path = []
+ @symbols = {}
+ @current_array_index = []
+ end
+ # rubocop:enable Lint/UnusedMethodArgument
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb b/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb
new file mode 100644
index 00000000000..991c9e493bc
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/symbolized_markdown_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ # Helper functions
+ module SymbolizedMarkdownHelper
+ def ___(symbol = nil, content = '')
+ { symbol: symbol, content: content }
+ end
+
+ def symbolize_array(symbol, content)
+ if content.is_a?(Array)
+ content.map.with_index { |l, idx| ___(symbol / idx, yield(l)) }
+ else
+ content.split("\n").map { |c| ___(symbol, c) }
+ end
+ end
+ end
+
+ # Simple wrapper for a string
+ class JsonSymbol < String
+ def /(other)
+ JsonSymbol.new((other.is_a?(Array) ? [self, *other] : [self, other]).join('.'))
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb b/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb
new file mode 100644
index 00000000000..f98e5f68086
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/transformed_notebook.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ # Notebook that was transformed into md, including location of source cells
+ class TransformedNotebook
+ attr_reader :blocks
+
+ def as_text
+ @blocks.map { |b| b[:content].gsub(/\n/, '\\n') }.join("\n")
+ end
+
+ private
+
+ def initialize(lines = [], symbol_map = {})
+ @blocks = lines.map do |line|
+ { content: line[:content], source_symbol: (symbol = line[:symbol]), source_line: symbol && symbol_map[symbol] }
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/transformer.rb b/gems/ipynbdiff/lib/ipynb_diff/transformer.rb
new file mode 100644
index 00000000000..2b386168b5d
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/transformer.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'yaml'
+require 'ipynb_diff/output_transformer'
+require 'ipynb_diff/symbolized_markdown_helper'
+require 'ipynb_diff/symbol_map'
+require 'ipynb_diff/transformed_notebook'
+require 'oj'
+
+module IpynbDiff
+ InvalidNotebookError = Class.new(StandardError)
+
+ # Returns a markdown version of the Jupyter Notebook
+ class Transformer
+ include SymbolizedMarkdownHelper
+
+ @include_frontmatter = true
+
+ def initialize(include_frontmatter: true, hide_images: false)
+ @include_frontmatter = include_frontmatter
+ @hide_images = hide_images
+ @out_transformer = OutputTransformer.new(hide_images)
+ end
+
+ def validate_notebook(notebook)
+ notebook_json = Oj::Parser.usual.parse(notebook)
+
+ return notebook_json if notebook_json&.key?('cells')
+
+ raise InvalidNotebookError
+ rescue EncodingError, Oj::ParseError, JSON::ParserError
+ raise InvalidNotebookError
+ end
+
+ def transform(notebook)
+ return TransformedNotebook.new unless notebook
+
+ notebook_json = validate_notebook(notebook)
+ transformed = transform_document(notebook_json)
+ symbol_map = SymbolMap.parse(notebook)
+
+ TransformedNotebook.new(transformed, symbol_map)
+ end
+
+ def transform_document(notebook)
+ symbol = JsonSymbol.new('.cells')
+
+ transformed_blocks = notebook['cells'].map.with_index do |cell, idx|
+ decorate_cell(transform_cell(cell, notebook, symbol / idx), cell, symbol / idx)
+ end
+
+ transformed_blocks.prepend(transform_metadata(notebook)) if @include_frontmatter
+ transformed_blocks.flatten
+ end
+
+ def decorate_cell(rows, cell, symbol)
+ tags = cell['metadata']&.fetch('tags', [])
+ type = cell['cell_type'] || 'raw'
+
+ [
+ ___(symbol, %(%% Cell type:#{type} id:#{cell['id']} tags:#{tags&.join(',')})),
+ ___,
+ rows,
+ ___
+ ]
+ end
+
+ def transform_cell(cell, notebook, symbol)
+ cell['cell_type'] == 'code' ? transform_code_cell(cell, notebook, symbol) : transform_text_cell(cell, symbol)
+ end
+
+ def transform_code_cell(cell, notebook, symbol)
+ [
+ ___(symbol / 'source', %(``` #{notebook.dig('metadata', 'kernelspec', 'language') || ''})),
+ symbolize_array(symbol / 'source', cell['source'], &:rstrip),
+ ___(nil, '```'),
+ transform_outputs(cell['outputs'], symbol)
+ ]
+ end
+
+ def transform_outputs(outputs, symbol)
+ transformed = outputs.map
+ .with_index { |output, i| @out_transformer.transform(output, symbol / ['outputs', i]) }
+ .compact
+ .map { |el| [___, el] }
+
+ [
+ transformed.empty? ? [] : [___, ___(symbol / 'outputs', '%% Output')],
+ transformed
+ ]
+ end
+
+ def transform_text_cell(cell, symbol)
+ symbolize_array(symbol / 'source', cell['source'], &:rstrip)
+ end
+
+ def transform_metadata(notebook_json)
+ as_yaml = {
+ 'jupyter' => {
+ 'kernelspec' => notebook_json['metadata']['kernelspec'],
+ 'language_info' => notebook_json['metadata']['language_info'],
+ 'nbformat' => notebook_json['nbformat'],
+ 'nbformat_minor' => notebook_json['nbformat_minor']
+ }
+ }.to_yaml
+
+ as_yaml.split("\n").map { |l| ___(nil, l) }.append(___(nil, '---'), ___)
+ end
+ end
+end
diff --git a/gems/ipynbdiff/lib/ipynb_diff/version.rb b/gems/ipynbdiff/lib/ipynb_diff/version.rb
new file mode 100644
index 00000000000..1a407f9c0fa
--- /dev/null
+++ b/gems/ipynbdiff/lib/ipynb_diff/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ module Version
+ VERSION = '0.4.7'
+ end
+end
diff --git a/gems/ipynbdiff/spec/benchmark.rb b/gems/ipynbdiff/spec/benchmark.rb
new file mode 100644
index 00000000000..514c8700183
--- /dev/null
+++ b/gems/ipynbdiff/spec/benchmark.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'ipynbdiff'
+require 'benchmark'
+require 'benchmark/memory'
+require_relative 'test_helper'
+
+# rubocop:disable Layout/LineLength
+large_cell = '{
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "24f32781-48bf-4378-b30c-ccdce7b05ba0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABj0AAAHwCAYAAAD91q10AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/MnkTPAAAACXBIWXMAAA9hAAAPYQGoP6dpAABPSUlEQVR4nO39eZCfd34f+L2fxkmAAAiCBO/7PobHkCAxIEjcV/eWHTl/yOVKvFaloo3kTZXjlKUa2bVS4tiyna2JElmudaUqK+0m9m4ceVVxutEAiIMXeIEEObyG4PC+QPAEQIA4+8kfD1pf/DgkhwC78XT/+vWqYmHQnybxLml6COCNz/dT1XUdAAAAAACA8a6n7QAAAAAAAAAjQekBAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BUmtx3g66qqqpJcnGR/21kAAAAAAIAxYVaSD+q6rr/rk8Zc6ZGm8Hiv7RAAAAAAAMCYcmmS97/rE8Zi6bE/Sd59993Mnj277SwAAAAAAECL9u3bl8suuyz5Hi9EjcXSI0kye/ZspQcAAAAAAPC9OWQOAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQegAAAAAAAF1B6QEAAAAAAHQFpQcAAAAAANAVlB4AAAAAAEBXUHoAAAAAAABdQekBAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXeGUSo+qqn5aVdXTVVXtr6pqT1VVf1VV1Q1f+5w/r6qq/tpfT4xsbAAAAAAA6DKffJJs2dJ2inFt8il+/pIkf5bk6RN/7z9LsrGqqpvruj5w0ucNJvmtk75/5AelBAAAAACAblPXyXPPJf39ycBA8sQTyfTpyaefJmed1Xa6cemUSo+6rtee/P2qqn4ryZ4kdyV5+KTR4bqud//weAAAAAAA0EX27082by5FxwcfdM6vvz55773kuuvayTfOneqmx9fNOfHtZ1/7+NKqqvYk+SLJQ0n+cV3Xe77pH1BV1bQk00760KwfmAkAAAAAAMaO115rSo7+/uThh5MjJz2ONGNGsnJl0teX9PYml17aXs4ucNqlR1VVVZKfJXm0rusXTxqtT/Ifkryd5Kok/zTJlqqq7qrr+vA3/KN+muQPTzcHAAAAAACMKYcPN+XG8DbHa691zq+5pik5+vqSBx5onrRiRFR1XZ/e31hVf5akL8niuq7f+47PuyhNAfK367r+j98w/6ZNj/f27t2b2bNnn1Y2AAAAAAA4o95/vyk4BgaSTZuSAyedwZ4ypSk3enubouP665Oqai/rOLNv377MmTMnSebUdb3vuz73tDY9qqr60yR/I8kD31V4JEld1x9WVfV2km98gOzE9sdfb4BU/h8NAAAAAMBYd/x48tRT5dmq557rnF94YSk5Vq5M/CH/M+KUSo8TT1r9aZLfSLK0rus3v8ffMy/JZUk+PK2EAAAAAAAwFnz2WbJhQ1NyDA4mn35aZlWV3HNPebbqjjuSnp7Wok5Up7rp8WdJ/k6Sv5lkf1VVF574+N66rr+qqursJH+U5C/TlBxXJvnnST5J8j+NRGAAAAAAADgj6jp54YXmyar+/mT79mRoqMznzEnWrGlKjrVrk/nz28tKklMvPX7nxLfbvvbx30ry50mOJ/lRkr+b5Jw0xcfWJL9Z1/X+0w0JAAAAAABnxIEDyZYt5Qj5u+92zm+5pWxzLFqUTD6tKxKMklP6/0Zd1995cKOu66+SrPlBiQAAAAAA4Ex6441ym2PbtuTw4TKbPj1ZsaIpOXp7kyuuaC0mv54KCgAAAACAieXo0eTRR0vR8YtfdM6vuKJscyxblpx1Vjs5OWVKDwAAAAAAut/u3cn69U3JsWlTsm9fmU2alCxeXIqOm25qDpMz7ig9AAAAAADoPkNDyY4d5Qj5jh2d8/PPb56r6u1NVq9OzjmnlZiMLKUHAAAAAADdYe/eZOPGpuRYvz7Zs6dzfvfdTcnR19f8556ednIyapQeAAAAAACMT3WdvPJKU3IMDDR3Oo4dK/NZs5otjr6+ZN265MIL28vKGaH0AAAAAABg/Pjqq2TbtnKE/K23Ouc33lhuc9x3XzJ1ahspaYnSAwAAAACAse2dd8o2x+bNTfExbNq0ZNmy8mzV1Ve3l5PWKT0AAAAAABhbjh1LHn+8bHO8+GLn/NJLyzbH8uXJzJnt5GTMUXoAAAAAANC+jz9OBgebkmPDhuSLL8qspydZtKgUHbfemlRVa1EZu5QeAAAAAACceXWd7NxZtjmeeqr52LB585K1a5uSY82a5Nxz28vKuKH0AAAAAADgzNi/P9m0qbnNMTCQfPhh5/yOO8o2xz33JJMmtRKT8UvpAQAAAADA6Nm1q2xzPPxwcvRomc2cmaxa1ZQc69Yll1zSXk66gtIDAAAAAICRc/hw8tBDTckxMJD88ped82uvLdscDzyQTJvWTk66ktIDAAAAAIAf5v33m4Kjvz958MHkwIEymzIlWbKkFB3XXddeTrqe0gMAAAAAgFNz/Hjy5JPl2arnn++cX3RR0tvblBwrVyazZrWTkwlH6QEAAAAAwK/32WfJ4GBTcgwONt8fVlXJvfeWbY477mg+BmeY0gMAAAAAgF9V18kLL5RtjscfT4aGyvycc5K1a5uNjrVrk/PPby0qDFN6AAAAAADQOHAg2by5HCF/773O+a23lm2On/wkmey3mBlb/DcSAAAAAGAie/31coR827bk8OEyO+usZMWKZpujtze54orWYsL3ofQAAAAAAJhIjhxJHn20PFv16qud8yuvLNscS5c2xQeME0oPAAAAAIBut3t3s80xMJBs3Jjs319mkycnixeXouPGGx0hZ9xSegAAAAAAdJuhoWTHjrLN8cwznfP588uTVatXJ3PmtJMTRpjSAwAAAACgG3zxRbPFMTCQrF+f7NnTOb/77rLNcdddSU9PKzFhNCk9AAAAAADGo7pOXnmlbHM8+mhy/HiZz57dbHH09ibr1iUXXtheVjhDlB4AAAAAAOPFV18lW7c2JcfAQPLWW53zG28s2xz33ZdMndpKTGiL0gMAAAAAYCx7++1ScmzZ0hQfw6ZNS5Yta7Y5+vqSq69uLyeMAUoPAAAAAICx5NixZPv28mzVSy91zi+9tGxzLF+ezJzZTk4Yg5QeAAAAAABt+/jj5vh4f3+yYUOyd2+Z9fQkixaVouPWW5Oqai8rjGFKDwAAAACAM21oKNm5s2xzPP10c5h82Lx5zfHxvr7mGPm557aXFcYRpQcAAAAAwJmwb1/y4IPlPsfu3Z3zO+8stznuuSeZNKmdnDCOKT0AAAAAAEZDXSe7dpVtjkceSY4eLfOZM5NVq5qSo7c3ufji9rJCl1B6AAAAAACMlEOHkoceKtscr7/eOb/uunKb4/77k2nT2skJXUrpAQAAAADwQ7z3XlNw9Pc3z1cdPFhmU6cmS5aUZ6uuu669nDABKD0AAAAAAE7F8ePJE0+UZ6t+/vPO+cUXl5Jj5crk7LPbyQkTkNIDAAAAAODX+fTTZHCw2egYHEw++6zMqipZuLA8W3X77c3HgDNO6QEAAAAA8HV13WxwDG9zPPFEMjRU5nPnJmvXNhsda9cm553XXlbgryk9AAAAAACS5Msvk82bm22OgYHmVsfJfvSjss2xcGEy2W+vwljjqxIAAAAAmLhef71sc2zblhw5UmZnndXc5Ojtbf66/PLWYgLfj9IDAAAAAJg4jhxJHnmkFB27dnXOr7qqbHMsXZpMn95KTOD0KD0AAAAAgO724YflyapNm5L9+8ts8uTk/vubTY6+vuTGGx0hh3FM6QEAAAAAdJehoeTpp8s2x7PPds7nzy8lx6pVyZw57eQERpzSAwAAAAAY/774ItmwoSk5BgeTjz/unC9Y0JQcvb3JXXclPT2txARGl9IDAAAAABh/6jp56aXmyar+/uSxx5Ljx8t89uxk9eqm6Fi3LrnggvayAmeM0gMAAAAAGB8OHky2bm1KjoGB5O23O+c33VSOkN93XzJlSjs5gdYoPQAAAACAseutt8ptjq1bk0OHymzatGT58vJs1VVXtRYTGBuUHgAAAADA2HH0aLJ9eyk6Xn65c37ZZWWbY/nyZMaMdnICY5LSAwAAAABo1549yfr1TcmxcWOyd2+ZTZqULFpUio5bbkmqqr2swJim9AAAAAAAzqyhoeTZZ8s2x44dzWHyYeed1xwf7+1N1qxJ5s5tLyswrig9AAAAAIDRt29fsmlTOUL+0Ued8x//uCk5+vqSBQuaDQ+AU6T0AAAAAABGXl0nr75atjkeeSQ5dqzMzz47WbWqKTnWrUsuvri9rEDXUHoAAAAAACPj0KFk27ayzfHGG53z668v2xz3359Mm9ZKTKB7KT0AAAAAgNP37rtNwdHfn2zenBw8WGZTpyZLl5ai49prW4sJTAxKDwAAAADg+zt2LHniifJs1QsvdM4vvrgpOPr6khUrmmesAM4QpQcAAAAA8N0++STZsKEpOQYHk88/L7OenmThwrLNcfvtSVW1lxWY0JQeAAAAAECnuk6ef75sczz5ZDI0VOZz5yZr1zYlx5o1yXnntZcV4CRKDwAAAAAg+fLL5MEHyxHyDz7onN92W1Ny9PY2mx2T/dYiMPb4XyYAAAAAmKh++cuyzfHQQ8mRI2U2Y0Zzk2O46LjssvZyAnxPSg8AAAAAmCiOHEkefrhsc+za1Tm/+upyhHzJkmT69HZyApwmpQcAAAAAdLMPPkjWr2+Kjk2bmmeshk2enNx/fyk6brjBEXJgXFN6AAAAAEA3OX48efrp8mzVzp2d8wsuaJ6r6utLVq1KZs9uJyfAKFB6AAAAAMB49/nnyYYNTckxOJh88kmZVVWyYEG5zfHjHyc9Pe1lBRhFSg8AAAAAGG/qOnnppbLNsX17s+ExbM6cZM2apuRYty6ZP7+9rABnkNIDAAAAAMaDgweTLVvKEfJ33umc33xzuc2xaFEyZUo7OQFapPQAAAAAgLHqzTdLybF1a3LoUJlNn54sX16erbryytZiAowVSg8AAAAAGCuOHk0efbQpOfr7k1de6ZxffnnZ5li2LJkxo52cAGOU0gMAAAAA2vTRR8n69U3JsXFjsm9fmU2alNx3Xyk6br65OUwOwDdSegAAAADAmTQ0lDzzTDlCvmNH5/z885vj4319yapVydy57eQEGIeUHgAAAAAw2vbuTTZtakqO9eub7Y6T3XVXc5ejry9ZsCDp6WknJ8A4p/QAAAAAgJFW18kvflG2OR59NDl2rMxnzWq2OPr6mq2Oiy5qLytAF1F6AAAAAMBIOHQo2bq1HCF/883O+Q03NCVHb29y//3J1Knt5AToYkoPAAAAADhd775btjk2b06++qrMpk5Nli4tR8ivuaa1mAATxSmVHlVV/TTJ30pyY5KvkmxP8vt1Xb960udUSf4wyW8nmZvkySR/v67rl0YqNAAAAAC04tix5PHHyzbHCy90zi+5pGxzrFiRnH12OzkBJqhT3fRYkuTPkjx94u/9Z0k2VlV1c13XB058zu8l+YdJ/l6SXUn+SZJNVVXdUNf1/hFJDQAAAABnyiefJIODTcmxYUPy+edl1tOTLFxYtjluuy2pqvayAkxwVV3Xp/83V9X5SfYkWVLX9cMntjw+SPIndV3/yxOfMy3JR2k2Qv7t9/hnzk6yd+/evZk9e/ZpZwMAAACA01LXyXPPNSXHwEDyxBPNx4ade26ydm1TcqxZk8yb11pUgIlg3759mTNnTpLMqet633d97g+96THnxLefnfj2qiQXJtk4/Al1XR+uquqhJIuS/ErpcaIUmXbSh2b9wEwAAAAAcGr2729ucgwXHR980Dm/7bayzXHvvclkp3IBxqLT/l/nE1sdP0vyaF3XL5748IUnvv3oa5/+UZIrvuUf9dM0N0AAAAAA4Mx57bVyhPzhh5MjR8psxoxk5cpyn+PSS9vLCcD39kMq6X+d5LYki79h9vU3s6pv+NiwP05TngybleS9H5ALAAAAAH7V4cNNuTG8zfHaa53za64pJceSJcn06e3kBOC0nVbpUVXVnyb5G0keqOv65IJi94lvL0zy4Ukfn59f3f5I0jx/leTwSf/s04kEAAAAAL/q/feT9eubouPBB5MvvyyzyZOTBx4oz1Zdf70j5ADj3CmVHieetPrTJL+RZGld129+7VPeTFN8rEqy88TfMzXJkiS//4PTAgAAAMB3OX48eeqp8mzVc891zi+8sNnk6Otrnq+aPbuVmACMjlPd9PizJH8nyd9Msr+qquEbHnvruv6qruu6qqo/SfIHVVW9luS1JH+Q5GCSfzdCmQEAAACg+OyzZMOGpuQYHEw+/bTMqiq5557ybNWddyY9Pe1lBWBUnWrp8Tsnvt32tY//VpI/P/Gf/1WSs5L8myRzkzyZZHVd1/tPLyIAAAAAnKSukxdeaO5y9Pcn27cnQ0NlPmdOsmZNU3SsXZvMn99eVgDOqKquv+2+eDuqqpqdZO/evXsz23ohAAAAAEly4ECyZUs5Qv7uu53zW24ptzkWLWrudQDQFfbt25c5c+YkyZy6rvd91+f6X38AAAAAxqY33ii3ObZtSw4fLrPp05MVK8qzVVdc0VpMAMYOpQcAAAAAY8ORI8ljj5Wi4xe/6JxfcUXZ5li2LDnrrHZyAjBmKT0AAAAAaM/u3cn69U3JsXFjsv+ks7CTJiWLF5ei46abmsPkAPAtlB4AAAAAnDlDQ8mOHeU2x44dnfPzz2+eq+rtTVavTs45p5WYAIxPSg8AAAAARtcXXySbNjVFx/r1yZ49nfO77irbHHffnfT0tBITgPFP6QEAAADAyKrr5JVXym2Oxx5Ljh0r81mzmi2Ovr5k3brkwgvbywpAV1F6AAAAAPDDffVVsm1bKTreeqtzfsMNZZtj8eJk6tQ2UgLQ5ZQeAAAAAJyed94pJceWLU3xMWzatGTp0qbk6O1NrrmmtZgATBxKDwAAAAC+n2PHku3byxHyF1/snF96aSk5VqxIZs5sJycAE5bSAwAAAIBv9/HHyeBgU3Rs2NAcJR/W05P85Cfl2aof/SipqtaiAoDSAwAAAICirpOdO8s2x5NPNh8bdu65zfHxvr7mGPm8ee1lBYCvUXoAAAAATHT79ycPPliKjg8/7JzffnvZ5rj33mTSpHZyAsCvofQAAAAAmIh27SpHyB9+ODl6tMxmzkxWrmxKjnXrmlsdADAOKD0AAAAAJoLDh5tyY7jo+OUvO+fXXFO2OZYsSaZNaycnAPwASg8AAACAbvX++81zVf39zfNVBw6U2ZQpyQMPlKLj+uvbywkAI0TpAQAAANAtjh9vDo8PFx3PPdc5v+iipLe3KTlWrkxmzWolJgCMFqUHAAAAwHj22WfJhg1NyTE4mHz6aZlVVXLPPWWb4447kp6e1qICwGhTegAAAACMJ3WdvPBCuc3x+OPJ0FCZn3NOsmZNU3KsXZucf35rUQHgTFN6AAAAAIx1Bw4kmzc3JcfAQPLee53zW29tSo7e3mTRomSy3/IBYGLyb0AAAACAsej118ttjm3bksOHy+yss5Lly0vRccUVrcUEgLFE6QEAAAAwFhw5kjz6aHm26tVXO+dXXllucyxd2hQfAEAHpQcAAABAW3bvbrY5BgaSjRuT/fvLbPLkZPHiZpOjry+56abmMDkA8K2UHgAAAABnytBQ8vTT5dmqZ57pnM+fn6xb15Qcq1cnc+a0kxMAximlBwAAAMBo+uKLZoujvz9Zvz75+OPO+d13l2er7ror6elpJSYAdAOlBwAAAMBIquvk5ZfLbY7HHkuOHy/z2bObLY7e3mar48IL28sKAF1G6QEAAADwQ331VbJ1ayk63n67c37jjWWb4777kqlT28kJAF1O6QEAAABwOt5+u5QcW7Ykhw6V2bRpybJl5Qj51Ve3lxMAJhClBwAAAMD3cexYsn17KTpeeqlzfumlZZtj+fJk5sx2cgLABKb0AAAAAPg2H3/cHB/v7082bEj27i2znp5k0aJSdNx6a1JV7WUFAJQeAAAAAH9taCjZuTMZGGiKjqeeag6TD5s3L1m7tik51qxJzj23vawAwK9QegAAAAAT2/79yaZNTckxMJDs3t05v+OOss1xzz3JpEmtxAQAfj2lBwAAADCx1HWya1fZ5nj44eTo0TKfOTNZtaopOdatSy65pL2sAMApUXoAAAAA3e/w4eShh8oR8tdf75xfd11TcvT2Jg88kEyb1k5OAOAHUXoAAAAA3en998uTVQ8+mBw4UGZTpiRLlpRnq667rr2cAMCIUXoAAAAA3eH48eTJJ8s2x/PPd84vvrjZ5OjrS1asSGbNaicnADBqlB4AAADA+PXZZ8ngYFNyDA423x9WVcnCheXZqjvuaD4GAHQtpQcAAAAwftR18vOfl2erHn88GRoq83POSdaubYqOtWuT885rLSoAcOYpPQAAAICx7cCBZPPmUnS8917n/Ec/Krc5Fi5MJvvtDgCYqPwsAAAAABh7Xn+93ObYti05cqTMzjorWbmyebKqtze5/PLWYgIAY4vSAwAAAGjfkSPJI4+UomPXrs75VVeVbY6lS5Pp01uJCQCMbUoPAAAAoB0ffpisX9+UHJs2Jfv3l9nkycn99zebHH19yY03OkIOAPxaSg8AAADgzBgaSp5+umxzPPts53z+/FJyrFqVzJnTTk4AYNxSegAAAACj54svkg0bmpJjcDD5+OPO+YIFTcnR25vcdVfS09NKTACgOyg9AAAAgJFT18nLL5dtjsceS44fL/PZs5PVq5uiY9265IIL2ssKAHQdpQcAAADwwxw8mGzd2pQcAwPJ2293zm+6qRwhv+++ZMqUdnICAF1P6QEAAACcurfeagqO/v5ky5bk0KEymzYtWbasFB1XXdVaTABgYlF6AAAAAL/e0aPJ9u3l2aqXX+6cX3ZZKTmWL09mzGgnJwAwoSk9AAAAgG+2Z0+yfn2z0bFhQ7J3b5lNmpQsWlSOkN96a1JV7WUFAIjSAwAAABg2NJTs3Fm2OZ5+ujlMPmzevOb4eF9fsmZNMndue1kBAL6B0gMAAAAmsn37kk2bmpJj/fpk9+7O+Z13lmerFixoNjwAAMYopQcAAABMJHWd7NpVtjkeeaS51zHs7LOTVauaJ6t6e5OLL24vKwDAKVJ6AAAAQLc7dCh56KFSdLzxRuf8uuvKNsf99yfTprWTEwDgB1J6AAAAQDd6772m4BgYSB58MDl4sMymTk2WLClHyK+7rr2cAAAjSOkBAAAA3eDYseTJJ8s2x89/3jm/+OKyzbFiRfOMFQBAl1F6AAAAwHj16afJ4GBTcmzYkHz2WZn19CQLFzabHH19ye23J1XVXlYAgDNA6QEAAADjRV0nzz/fPFnV35888UQyNFTmc+cma9c2JceaNcl557WXFQCgBUoPAAAAGMu+/DLZvLnc53j//c75bbeV2xwLFyaT/VIfAJi4/EwIAAAAxppf/rLc5njooeTIkTKbMaO5yTFcdFx2WXs5AQDGGKUHAAAAtO3IkeThh8uzVbt2dc6vvrocIV+yJJk+vZ2cAABjnNIDAAAA2vDhh6Xk2LSpecZq2OTJyQMPlG2OG25whBwA4HtQegAAAMCZcPx48vTT5TbHs892zi+4oCk4+vqSVauS2bPbyQkAMI4pPQAAAGC0fP55snFjU3SsX5988kmZVVWyYEHZ5vjxj5OenvayAgB0AaUHAAAAjJS6Tl56qRwh37692fAYNnt2smZNU3SsW5fMn99eVgCALqT0AAAAgB/i4MFky5Zyn+OddzrnN99cjpAvWpRMmdJOTgCACUDpAQAAAKfqrbfKNsfWrcmhQ2U2fXqyfHm5z3HllW2lBACYcJQeAAAA8OscPZo89lg5Qv7yy53zyy8v2xzLliUzZrSTEwBgglN6AAAAwDfZs6c5Pt7f3xwj37u3zCZNSu67r2xz3HJLc5gcAIBWKT0AAAAgSYaGkmefLc9W7djRHCYfdt55zfHxvr5k9epk7tz2sgIA8I2UHgAAAExc+/Y1WxwDA81fH33UOf/xj8uzVXff3Wx4AAAwZik9AAAAmDjqOnn11bLN8cgjybFjZX722cmqVU3JsW5dcvHF7WUFAOCUKT0AAADobocOJdu2lSPkb7zROb/++rLNcf/9ydSprcQEAOCHO+XSo6qqB5L8oyR3JbkoyW/Udf1XJ83/PMl//rW/7cm6rheefkwAAAA4Be++2xQc/f3J5s3JwYNlNnVqsnRpU3L09ibXXttaTAAARtbpbHrMTPJ8kv82yV9+y+cMJvmtk75/5DR+HAAAAPh+jh1LnniiPFv1wgud80suaQqOvr5kxYrmGSsAALrOKZcedV2vT7I+Saqq+rZPO1zX9e4fkAsAAAC+2yefJIODTcmxYUPy+edl1tOTLFxYnq267bbk238NCwBAlxitmx5Lq6rak+SLJA8l+cd1Xe/5pk+sqmpakmknfWjWKGUCAABgPKvr5Lnnym2OJ55oPjbs3HOTtWubjY61a5N581qLCgBAO0aj9Fif5D8keTvJVUn+aZItVVXdVdf14W/4/J8m+cNRyAEAAMB49+WXyYMPlqLjgw8657fdVrY57r03mTxaf7YPAIDxYMR/NljX9f940ndfrKpqR5oCpC/Jf/yGv+WPk/zspO/PSvLeSOcCAABgnHjttXKb4+GHkyMnnYmcMSNZubIcIb/00vZyAgAw5oz6H4Gp6/rDqqreTnLdt8wPJ/nrDZDvuBMCAABANzp8uCk3hrc5Xnutc3711WWbY8mSZPr0dnICADDmjXrpUVXVvCSXJflwtH8sAAAAxokPPmgKjv7+5vmqL78ss8mTkwceKEXH9dc7Qg4AwPdyyqVHVVVnJ7n2pA9dVVXVHUk+O/HXHyX5yzQlx5VJ/nmST5L8Tz8sKgAAAOPW8ePJU0+VbY6dOzvnF17YPFfV19c8XzV7djs5AQAY105n0+PuJFtP+v7wPY6/SPI7SX6U5O8mOSdN8bE1yW/Wdb3/9GMCAAAw7nz+ebJhQ1N0DA4mn3xSZlWVLFhQtjnuvDPp6WkvKwAAXeGUS4+6rrcl+a694jWnnQYAAIDxq66TF18sR8i3b0+Ghsp8zpxkzZqm5Fi7Npk/v72sAAB0pVG/6QEAAEAXO3gw2by53Od4993O+S23lGerFi1KpkxpJycAABOC0gMAAIBT8+abZZtj69bk8OEymz49Wb68KTl6e5Mrr2wtJgAAE4/SAwAAgO929Gjy6KNlm+OVVzrnV1xRbnMsXZrMmNFKTAAAUHoAAADwqz76KFm/vik5Nm5M9u0rs0mTkvvuK0XHzTc3h8kBAKBlSg8AAACag+PPPFOerdqxo3N+/vnJunXNk1WrVydz57aTEwAAvoPSAwAAYKLau7fZ4hgYaLY6Pvqoc37XXeU2x4IFSU9POzkBAOB7UnoAAABMFHXd3OMYvs3x6KPJsWNlPmtWsmpVU3SsW5dcdFF7WQEA4DQoPQAAALrZV18l27aVZ6veeqtzfsMN5TbH4sXJ1KltpAQAgBGh9AAAAOg277zTFBwDA8nmzU3xMWzq1GTZsvJs1TXXtJcTAABGmNIDAABgvDt2LHn88bLN8eKLnfNLL20Kjr6+ZMWKZObMdnICAMAoU3oAAACMRx9/nAwONiXHhg3JF1+UWU9P8pOflGerfvSjpKpaiwoAAGeK0gMAAGA8qOtk587ybNWTTzYfG3buuc3x8b6+ZPXqZN689rICAEBLlB4AAABj1f79yYMPlqLjww8757ffXrY57r03mTSpnZwAADBGKD0AAADGkl27Ssnx0EPJ0aNlNnNmsnJlOUJ+ySXt5QQAgDFI6QEAANCmw4eThx8uR8h/+cvO+bXXlpJjyZJk2rR2cgIAwDig9AAAADjT3n+/2eTo72+erzpwoMymTEkeeKA8W3X99e3lBACAcUbpAQAAMNqOH0+eeqpsczz3XOf8oouaTY6+vub5qlmzWokJAADjndIDAABgNHz2WbJhQ1NyDA4mn35aZlWV3HNP2ea4446kp6e1qAAA0C2UHgAAACOhrpMXXijPVm3fngwNlfk55yRr1jQlx9q1yfnntxYVAAC6ldIDAADgdB04kGzZ0pQcAwPJu+92zm+9tTxbtWhRMtkvwQAAYDT5GTcAAMCpeOONcptj27bk8OEyO+usZPnypuTo7U2uuKK1mAAAMBEpPQAAAL7LkSPJY4+VouMXv+icX3llKTmWLWuKDwAAoBVKDwAAgK/bvTtZv74pOTZuTPbvL7NJk5LFi8sR8ptuag6TAwAArVN6AAAADA0lO3aUbY5nnumcz5+frFvXlByrVjVHyQEAgDFH6QEAAExMX3zRbHEMDDRbHXv2dM7vvrs8W3X33UlPTysxAQCA70/pAQAATAx1nbzyStnmePTR5PjxMp81K1m9uik61q1LLrywvawAAMBpUXoAAADd66uvkq1bm5JjYCB5663O+Y03ltsc992XTJ3aSkwAAGBkKD0AAIDu8vbbpeTYsqUpPoZNm5YsW9Y8WdXXl1x9dXs5AQCAEaf0AAAAxrdjx5Lt28uzVS+91Dm/9NKyzbF8eTJzZjs5AQCAUaf0AAAAxp+PP26Oj/f3Jxs2JHv3lllPT7JoUSk6br01qar2sgIAAGeM0gMAABj7hoaSnTubJ6v6+5OnnmoOkw+bNy9Zu7YpOdasSc49t72sAABAa5QeAADA2LR/f7JpU7nPsXt35/yOO8o2xz33JJMmtRITAAAYO5QeAADA2FDXya5dZZvj4YeTo0fLfObMZNWqpuRYty655JL2sgIAAGOS0gMAAGjP4cPJQw+VI+Svv945v/bass3xwAPJtGnt5AQAAMYFpQcAAHBmvfde2ebYvDk5cKDMpkxJlixpSo7e3uT669vLCQAAjDtKDwAAYHQdP548+WTZ5nj++c75RRc1BUdfX7JyZTJrVjs5AQCAcU/pAQAAjLzPPksGB5uSY3Cw+f6wqkruvbc8W3XHHc3HAAAAfiClBwAA8MPVdfLznzclx8BA8vjjydBQmZ9zTrJmTVNyrF2bnH9+a1EBAIDupfQAAABOz4EDzU2O4aLjvfc657feWrY5fvKTZLJffgAAAKPLrzoAAIDv7/XXy22ObduSI0fK7KyzkhUryhHyyy9vLSYAADAxKT0AAIBvd+RI8sgjZZvj1Vc751ddVUqOpUub4gMAAKAlSg8AAKDThx82BcfAQLJpU7J/f5lNnpwsXlyerbrxRkfIAQCAMUPpAQAAE93QUPL00+XZqmef7ZzPn99scvT1JatWJXPmtJMTAADg11B6AADARPTFF8mGDU3JMTiYfPxx53zBgvJs1V13JT09rcQEAAA4FUoPAACYCOo6eeml5smq/v7ksceS48fLfPbsZPXqpuhYty654IL2sgIAAJwmpQcAAHSrgweTrVvLEfK33+6c33RTuc1x333JlCnt5AQAABghSg8AAOgmb71VSo4tW5JDh8ps2rRk+fJyn+Oqq1qLCQAAMBqUHgAAMJ4dPZps316OkL/8cuf8ssvKNsfy5cmMGe3kBAAAOAOUHgAAMN7s2ZOsX9+UHBs3Jnv3ltmkScmiRaXouOWWpKraywoAAHAGKT0AAGCsGxpKdu4s2xxPP90cJh82b15zfLyvL1mzJpk7t72sAAAALVJ6AADAWLRvX7JpU1NyrF+f7N7dOb/zzrLNsWBBs+EBAAAwwSk9AABgLKjrZNeuss3xyCPNvY5hZ5+drFrVHCHv7U0uvri9rAAAAGOU0gMAANpy6FDy0EOl6Hjjjc75ddeVbY7770+mTWsnJwAAwDih9AAAgDPpvfeSgYGm5HjwweTgwTKbOjVZsqQpOXp7m9IDAACA703pAQAAo+n48eSJJ8o2x89/3jm/+OKm4OjrS1aubJ6xAgAA4LQoPQAAYKR9+mkyONhsdAwOJp99VmZVlSxcWJ6tuv325mMAAAD8YEoPAAD4oeq62eAY3uZ44olkaKjM585N1q5tSo41a5LzzmsvKwAAQBdTegAAwOn48stk8+Zmm2NgoLnVcbLbbivPVi1cmEz2U28AAIDR5ldeAADwfb3+etnm2LYtOXKkzGbMSFasKEfIL7ustZgAAAATldIDAAC+zZEjySOPlKJj167O+dVXl5Jj6dJk+vRWYgIAANBQegAAwMk+/DBZv74pOTZtSvbvL7PJk5P77y9HyG+4wRFyAACAMUTpAQDAxDY0lDz9dNnmePbZzvkFF5TbHKtWJbNnt5MTAACAX0vpAQDAxPPFF8mGDU3JMTiYfPxxmVVVsmBB2ea4886kp6e1qAAAAHx/Sg8AALpfXScvvZQMDDRFx2OPJcePl/mcOcmaNc1Gx7p1yfz57WUFAADgtCk9AADoTgcPJlu3NiXHwEDy9tud85tvLtscixYlU6a0kxMAAIARo/QAAKB7vPVWuc2xdWty6FCZTZ+eLF9e7nNceWVbKQEAABglSg8AAMavo0eT7dtL0fHyy53zyy8v2xzLliUzZrSTEwAAgDNC6QEAwPiyZ0+yfn1TcmzcmOzdW2aTJiX33deUHL29yS23NIfJAQAAmBCUHgAAjG1DQ8mzz5Ztjh07msPkw847rzk+3teXrF6dzJ3bXlYAAABapfQAAGDs2bcv2bSpHCH/6KPO+Y9/XLY5FixoNjwAAACY8JQeAAC0r66TV18t2xyPPJIcO1bmZ5+drFrVFB3r1iUXX9xeVgAAAMasUy49qqp6IMk/SnJXkouS/EZd13910rxK8odJfjvJ3CRPJvn7dV2/NBKBAQDoEocOJdu2lW2ON97onF9/fbPJ0deX3H9/Mm1aKzEBAAAYP05n02NmkueT/LdJ/vIb5r+X5B8m+XtJdiX5J0k2VVV1Q13X+08zJwAA3eDdd5uCo78/2bw5OXiwzKZOTZYuLUXHtde2FhMAAIDx6ZRLj7qu1ydZnyTNUkdxYsvjHyT5Z3Vd/8cTH/vPk3yU5O8k+bc/LC4AAOPKsWPJE0+UZ6teeKFzfvHFTcHR15esWNE8YwUAAACnaaRvelyV5MIkG4c/UNf14aqqHkqyKN9QelRVNS3JyW8VzBrhTAAAnEmffJJs2NCUHIODyeefl1lPT7JwYdnmuP325Gt/kAYAAABO10iXHhee+Pajr338oyRXfMvf89M0N0AAABiP6jp5/vmyzfHkk8nQUJnPnZusXduUHGvWJOed115WAAAAutpIlx7D6q99v/qGjw374yQ/O+n7s5K8NxqhAAAYIV9+mTz4YDlC/sEHnfPbbmtKjt7eZrNj8mj9tBMAAACKkf7V5+4T316Y5MOTPj4/v7r9kaR5/irJ4eHvf/1OCAAAY8Qvf1m2OR56KDlypMxmzGhucgwXHZdd1l5OAAAAJqyRLj3eTFN8rEqyM0mqqpqaZEmS3x/hHwsAgNF05Ejy8MNlm2PXrs751VeXI+RLliTTp7eTEwAAAE445dKjqqqzk1x70oeuqqrqjiSf1XX9TlVVf5LkD6qqei3Ja0n+IMnBJP/uh8cFAGBUffBBsn59U3Rs2tQ8YzVs8uTk/vtL0XHDDY6QAwAAMKaczqbH3Um2nvT94Xscf5Hk7yX5V0nOSvJvksxN8mSS1XVd7z/9mAAAjIrjx5Onny7PVu3c2Tm/4ILmuaq+vmTVqmT27HZyAgAAwPdQ1fW33RdvR1VVs5Ps3bt3b2b7RTUAwMj7/PNkw4am5BgcTD75pMyqKlmwoNzm+PGPk56e9rICAAAw4e3bty9z5sxJkjl1Xe/7rs8d6ZseAACMNXWdvPRS2ebYvr3Z8Bg2Z06yZk1Tcqxbl8yf315WAAAA+AGUHgAA3ejgwWTLlnKE/J13Ouc331xucyxalEyZ0k5OAAAAGEFKDwCAbvHmm6Xk2Lo1OXSozKZPT5YvL89WXXllazEBAABgtCg9AADGq6NHk0cfbUqO/v7klVc655dfXrY5li1LZsxoJycAAACcIUoPAIDx5KOPkvXrm5Jj48Zk30n32yZNSu67rxQdN9/cHCYHAACACULpAQAwlg0NJc88U7Y5nn66c37++c3x8b6+ZNWqZO7cdnICAADAGKD0AAAYa/buTTZtakqO9eub7Y6T3XVXc5ejry9ZsCDp6WknJwAAAIwxSg8AgLbVdfKLXzQlR39/c6fj2LEynzWr2eLo62u2Oi66qL2sAAAAMIYpPQAA2nDoULJ1a3m26s03O+c33NCUHL29yf33J1OntpMTAAAAxhGlBwDAmfLuu2WbY/Pm5Kuvymzq1GTp0nKE/JprWosJAAAA45XSAwBgtBw7ljz+eNnmeOGFzvkll5SSY8WKZObMdnICAABAl1B6AACMpE8+SQYHm5Jjw4bk88/LrKcnWbiwFB233ZZUVXtZAQAAoMsoPQAAfoi6Tp57rik5BgaSJ55oPjbs3HOTtWubkmPNmmTevNaiAgAAQLdTegAAnKr9+5ubHMNFxwcfdM5vv70cIV+4MJk0qZ2cAAAAMMEoPQAAvo/XXitHyB9+ODlypMxmzEhWrixFx6WXtpcTAAAAJjClBwDANzl8uCk3hrc5Xnutc37NNeU2xwMPJNOnt5MTAAAA+GtKDwCAYe+/n6xf3xQdDz6YfPllmU2Z0pQbvb1N0XH99Y6QAwAAwBij9AAAJq7jx5OnnirPVj33XOf8wgtLybFyZTJ7disxAQAAgO9H6QEATCyffZZs2NCUHIODyaeflllVJffcU56tuuOOpKentagAAADAqVF6AADdra6TF15o7nL09yfbtydDQ2U+Z06yZk1Tcqxdm8yf315WAAAA4AdRegAA3efAgWTLlnKE/N13O+e33FK2ORYtSib7KREAAAB0A7/CBwC6wxtvlNsc27Ylhw+X2fTpyYoVTcnR25tccUVrMQEAAIDRo/QAAManI0eSxx4rRccvftE5v+KKss2xbFly1lnt5AQAAADOGKUHADB+7N6drF/flBwbNyb795fZpEnJ4sWl6LjppuYwOQAAADBhKD0AgLFraCjZsaPc5tixo3N+/vnNc1W9vcnq1ck557QSEwAAABgblB4AwNiyd2+zxdHf32x17NnTOb/rrrLNcffdSU9POzkBAACAMUfpAQC0q66TV14p2xyPPpocO1bms2Y1Wxx9fcm6dcmFF7aXFQAAABjTlB4AwJn31VfJtm3lCPlbb3XOb7ihbHMsXpxMndpGSgAAAGCcUXoAAGfGO++UkmPLlqb4GDZtWrJ0aVNy9PYm11zTWkwAAABg/FJ6AACj49ix5PHHS9Hx4oud80svLSXHihXJzJnt5AQAAAC6htIDABg5H3+cDA42JceGDckXX5RZT0/yk5+UZ6t+9KOkqlqLCgAAAHQfpQcAcPrqOtm5s2xzPPVU87Fh556brF3blBxr1iTz5rWXFQAAAOh6Sg8A4NTs359s2pQMDDR/ffhh5/yOO5onq/r6knvvTSZNaiUmAAAAMPEoPQCAX2/XrrLN8fDDydGjZTZzZrJyZbnPcckl7eUEAAAAJjSlBwDwqw4fTh56qCk5BgaSX/6yc37tteU2xwMPJNOmtZMTAAAA4CRKDwCg8d575cmqBx9MDhwosylTmnJjuOi4/vr2cgIAAAB8C6UHAExUx48nTz5Znq16/vnO+UUXldscK1cms2a1kxMAAADge1J6AMBE8tlnyeBgU3IMDjbfH1ZVzeHx4W2OO+5oPgYAAAAwTig9AKCb1XXy85+X2xyPP54MDZX5Oecka9c2Gx1r1ybnn99aVAAAAIAfSukBAN3mwIFk8+ZSdLz3Xuf81lvLNsdPfpJM9tMBAAAAoDv4XQ4A6Aavv15Kjm3bksOHy+yss5IVK5qSY9265IorWosJAAAAMJqUHgAwHh05kjz6aDlC/uqrnfOrrmpKjt7eZOnSpvgAAAAA6HJKDwAYL3bvbjY5+vuTTZuS/fvLbPLkZPHi8mzVjTc6Qg4AAABMOEoPABirhoaSHTvKNsczz3TO589vNjl6e5PVq5M5c9rJCQAAADBGKD0AYCz54otk48am5Fi/Pvn448753XeXbY677kp6elqJCQAAADAWKT0AoE11nbzyStnmePTR5PjxMp89u9ni6OtL1q5NLrywvawAAAAAY5zSAwDOtK++SrZubUqOgYHkrbc65zfeWLY5Fi9OpkxpJSYAAADAeKP0AIAz4e23S8mxZUtTfAybNi1ZtqwpOXp7k6uvbi8nAAAAwDim9ACA0XD0aPL44+XZqpde6pxfemnZ5li+PJk5s52cAAAAAF1E6QEAI+Xjj5vj4/39yYYNyd69ZdbTkyxaVIqOW29Nqqq9rAAAAABdSOkBAKdraCjZubNsczz9dHOYfNi8ecm6dU3JsXp1cu657WUFAAAAmACUHgBwKvbtSx58sNzn2L27c37nneU2xz33JJMmtZMTAAAAYAJSegDAd6nrZNeuss3xyCPNvY5hM2cmq1aVouPii9vLCgAAADDBKT0A4OsOHUoeeqhsc7z+euf8uuvKbY7770+mTWsnJwAAAAAdlB4AkCTvvdcUHP39zfNVBw+W2dSpyZIlzSZHX19TegAAAAAw5ig9AJiYjh9PnniiPFv18593zi++uJQcK1cmZ5/dTk4AAAAAvjelBwATx6efJoODzUbH4GDy2WdlVlXJwoXl2arbb28+BgAAAMC4ofQAoHvVdbPBMbzN8cQTydBQmc+dm6xd22x0rF2bnHdee1kBAAAA+MGUHgB0ly+/TDZvLkfI33+/c/6jH5VtjoULk8n+VQgAAADQLfxODwDj3+uvl22ObduSI0fKbMaMZMWKpuRYty65/PLWYgIAAAAwupQeAIw/R44kjzxSio5duzrnV11VtjmWLk2mT28lJgAAAABnltIDgPHhww+b56oGBpJNm5L9+8ts8uTk/vubkqO3N7nxRkfIAQAAACYgpQcAY9PQUPL002Wb49lnO+cXXNA8V9XXl6xalcyZ005OAAAAAMYMpQcAY8cXXyQbNjTbHOvXJx9/3DlfsKA8W/XjHyc9Pa3EBAAAAGBsUnoA0J66Tl56qSk5+vuTxx5Ljh8v89mzk9WryxHyCy5oLysAAAAAY57SA4Az6+DBZOvWpuQYGEjefrtzftNN5TbH4sXJlCnt5AQAAABg3FF6ADD63nqr3ObYujU5dKjMpk1Lli8vRcdVV7UWEwAAAIDxTekBwMg7erR5qmr42aqXX+6cX3ZZuc2xfHkyY0Y7OQEAAADoKkoPAEbGnj3N8fH+/mTjxmTv3jKbNClZtKgUHbfcklRVe1kBAAAA6EpKDwBOz9BQ8uyz5dmqHTuaw+TDzjuvOT7e19ccI587t72sAAAAAEwISg8Avr99+5otjoGB5q+PPuqc//jHzV2Ovr5kwYJmwwMAAAAAzpARLz2qqvqjJH/4tQ9/VNf1hSP9YwEwyuo6efXVss3xyCPJsWNlfvbZyapVTcmxbl1y8cXtZQUAAABgwhutTY+Xkqw86fvHR+nHAWCkHTqUbNvWlBwDA8kbb3TOr7++3OZYvDiZNq2VmAAAAADwdaNVehyr63r3KP2zARhp777bFBz9/cnmzcnBg2U2dWqyZEkpOq69tr2cAAAAAPAdRqv0uK6qqg+SHE7yZJI/qOv6jW/6xKqqpiU5+Y8JzxqlTAAMO3YseeKJ8mzVCy90zi+5pNzmWLGiecYKAAAAAMa40Sg9nkzyd5PsSnJBkn+SZHtVVbfUdf3pN3z+T/OrN0AAGGmffJIMDjYlx4YNyeefl1lPT7JwYVNy9PYmt9+eVFV7WQEAAADgNFR1XY/uD1BVM5O8nuRf1XX9s2+Yf9Omx3t79+7N7NmzRzUbQFer6+T558s2x5NPJkNDZT53brJ2bVN0rF2bzJvXXlYAAAAA+Bb79u3LnDlzkmROXdf7vutzR+t5q79W1/WBqqpeSHLdt8wPp3kGK0lS+ZPFAKfvyy+TBx8sR8g/+KBzfttt5TbHvfcmk0f9XwMAAAAAcMaM+u92ndjkuCnJI6P9YwFMSK+9Vo6QP/RQcuRImc2Y0dzkGH626rLL2ssJAAAAAKNsxEuPqqr+6yT/Kck7SeanuekxO8lfjPSPBTAhHTmSPPxwebbqtdc651dfXbY5lixJpk9vJycAAAAAnGGjselxaZJ/n+S8JB8neSLJwrqu3x6FHwtgYvjgg2T9+qbk2LSpecZq2OTJyQMPlG2OG25whBwAAACACWnES4+6rv/2SP8zASac48eTp58u2xw7d3bOL7igKTj6+pJVq5LZs9vJCQAAAABjiAu2AGPF558nGzY09znWr08++aTMqipZsKBsc/z4x0lPT3tZAQAAAGAMUnoAtKWuk5deKtsc27c3Gx7D5sxJ1qxpSo5165L589vLCgAAAADjgNID4Ew6eDDZsqUpOQYGknfe6ZzffHM5Qr5oUTJlSjs5AQAAAGAcUnoAjLa33irbHFu3JocOldn06cny5eXZqiuvbCslAAAAAIx7Sg+AkXb0aPLYY2Wb4+WXO+eXX162OZYtS2bMaCcnAAAAAHQZpQfASNizpzk+3t+fbNyY7N1bZpMmJffdV4qOm29uDpMDAAAAACNK6QFwOoaGkmefLc9W7djRHCYfdv75zfHxvr5k9erknHNaiwoAAAAAE4XSA+D72rev2eIYGGj++uijzvldd5XbHAsWJD097eQEAAAAgAlK6QHwbeo6efXVss3xyCPJsWNlPmtWsmpVU3SsW5dcdFF7WQEAAAAApQdAh0OHkm3byhHyN97onN9wQ7nNsXhxMnVqKzEBAAAAgF+l9AB4992m4OjvTzZvTg4eLLOpU5OlS0vRcc01rcUEAAAAAL6b0gOYeI4dS554ojxb9cILnfNLLiklx4oVycyZ7eQEAAAAAE6J0gOYGD75JBkcbEqODRuSzz8vs56eZOHCUnTcdltSVe1lBQAAAABOi9ID6E51nTz3XLnN8cQTzceGnXtusnZtU3KsWZPMm9daVAAAAABgZCg9gO7x5ZfJgw+WouODDzrnt9/elBy9vc1mx6RJ7eQEAAAAAEaF0gMY3157rdzmePjh5MiRMpsxI1m5shQdl17aXk4AAAAAYNQpPYDx5fDhptwY3uZ47bXO+dVXl9scS5Yk06e3kxMAAAAAOOOUHsDY98EHTcHR3988X/Xll2U2eXLywAOl6Lj+ekfIAQAAAGCCUnoAY8/x48lTT5Vtjp07O+cXXtg8V9XX1zxfNXt2OzkBAAAAgDFF6QGMDZ9/nmzY0BQdg4PJJ5+UWVUlCxaUbY4770x6etrLCgAAAACMSUoPoB11nbz4YjlCvn17MjRU5nPmJGvWNCXH2rXJ/PntZQUAAAAAxgWlB3DmHDyYbN5c7nO8+27n/JZbyrNVixYlU6a0kxMAAAAAGJeUHsDoevPNss2xdWty+HCZTZ+eLF/elBy9vcmVV7YWEwAAAAAY/5QewMg6ejR59NGyzfHKK53zK64otzmWLk1mzGglJgAAAADQfZQewA/30UfJ+vVNybFxY7JvX5lNmpTcd18pOm6+uTlMDgAAAAAwwpQewKkbGkqeeaY8W7VjR+f8/POTdeuaJ6tWr07mzm0nJwAAAAAwoSg9gO9n795mi6O/v9nq2LOnc37XXeU2x4IFSU9POzkBAAAAgAlL6QF8s7pu7nEM3+Z49NHk2LEynzUrWbWqKTrWrUsuuqi9rAAAAAAAUXoAJ/vqq2TbtvJs1Vtvdc5vuKHc5li8OJk6tY2UAAAAAADfSOkBE9077zQFx8BAsnlzU3wMmzo1WbasPFt1zTXt5QQAAAAA+DWUHjDRHDuWbN9enq168cXO+aWXNgVHX1+yYkUyc2Y7OQEAAAAATpHSAyaCjz9OBgebkmPDhuSLL8qspyf5yU/Ks1U/+lFSVa1FBQAAAAA4XUoP6EZ1nezcWW5zPPVU87Fh557bHB/v60tWr07mzWsvKwAAAADACFF6QLfYvz958MFyn+PDDzvnd9xRnq26995k0qRWYgIAAAAAjBalB4xnu3aVbY6HH06OHi2zmTOTlSvLEfJLLmkvJwAAAADAGaD0gPHk8OGm3BguOn75y875NdeU2xxLliTTprWTEwAAAACgBUoPGOvef795rqq/v3m+6sCBMpsyJXnggVJ0XH99ezkBAAAAAFqm9ICx5vjx5MknS9Hx3HOd84suKrc5Vq5MZs1qJSYAAAAAwFij9ICx4LPPkg0bmpJjcDD59NMyq6rknnvKNscddyQ9Pa1FBQAAAAAYq5Qe0Ia6Tl54oWxzbN+eDA2V+TnnJGvWNCXH2rXJ+ee3FhUAAAAAYLxQesCZcuBAsmVLU3IMDCTvvts5v/XW8mzVokXJZF+eAAAAAACnwu+qwmh6442m5OjvT7ZtSw4fLrOzzkqWL29Kjt7e5IorWosJAAAAANANlB4wko4cSR57rBQdv/hF5/zKK0vJsWxZU3wAAAAAADAilB7wQ+3enaxf35QcGzcm+/eX2aRJyeLF5Qj5TTc1h8kBAAAAABhxSg84VUNDyY4dZZvjmWc65/PnJ+vWNSXHqlXNUXIAAAAAAEad0gO+jy++aLY4BgaarY49ezrnd99dnq26++6kp6eVmAAAAAAAE5nSA75JXSevvFK2OR59NDl+vMxnz05Wr25KjnXrkgsvbC8rAAAAAABJlB5QfPVVsnVrU3IMDCRvvdU5v/HGcpvjvvuSqVNbiQkAAAAAwDdTejCxvf12KTm2bGmKj2HTpiXLlpVnq66+ur2cAAAAAAD8WkoPJpZjx5Lt28uzVS+91Dm/9NKyzbF8eTJzZjs5AQAAAAA4ZUoPut/HHzfHx/v7kw0bkr17y6ynJ1m0qBQdt96aVFV7WQEAAAAAOG1KD7rP0FCyc2fzZFV/f/LUU81h8mHz5jXHx/v6mmPk557bXlYAAAAAAEaM0oPusH9/smlTuc+xe3fn/I47yjbHPfckkya1EhMAAAAAgNGj9GB8qutk166yzfHww8nRo2U+c2ayalVTcqxbl1xySXtZAQAAAAA4I5QejB+HDycPPVSOkL/+euf82mvLNscDDyTTprWTEwAAAACAVig9GNvee69sc2zenBw4UGZTpiRLlpSi47rr2ssJAAAAAEDrlB6MLcePJ08+WbY5nn++c37RRUlvb1NyrFyZzJrVTk4AAAAAAMYcpQft++yzZHCwKTkGB5vvD6uq5N57yzbHHXc0HwMAAAAAgK9RenDm1XXy8583JcfAQPL448nQUJmfc06yZk1Tcqxdm5x/fmtRAQAAAAAYP5QenBkHDjQ3OYaLjvfe65zfemvZ5vjJT5LJ/qsJAAAAAMCp8TvLjJ7XXy+3ObZtS44cKbOzzkpWrGhKjt7e5PLLW4sJAAAAAEB3UHowco4cSR55pGxzvPpq5/yqq0rJsXRpU3wAAAAAAMAIUXrww3z4YVNwDAwkmzYl+/eX2eTJyeLF5dmqG290hBwAAAAAgFGj9ODUDA0lTz9dnq169tnO+fz5zSZHX1+yalUyZ047OQEAAAAAmHCUHvx6X3yRbNjQlByDg8nHH3fOFywoz1bddVfS09NKTAAAAAAAJjalB7+qrpOXXmqerOrvTx57LDl+vMxnz05Wr26KjnXrkgsuaC8rAAAAAACcoPSgcfBgsnVrOUL+9tud85tuKrc57rsvmTKlnZwAAAAAAPAtlB4T2VtvlZJjy5bk0KEymzYtWb683Oe46qrWYgIAAAAAwPeh9JhIjh5Ntm8vR8hffrlzftllZZtj+fJkxox2cgIAAAAAwGlQenS7PXuS9eubkmPjxmTv3jKbNClZtKgUHbfcklRVe1kBAAAAAOAHUHp0m6GhZOfOss3x9NPNYfJh8+Y1x8f7+pI1a5K5c9vLCgAAAAAAI2jUSo+qqn43yT9KclGSl5L8g7quHxmtH29C27cv2bSpKTnWr0927+6c33ln2eZYsKDZ8AAAAAAAgC4zKqVHVVW/meRPkvxukseS/BdJ1ldVdXNd1++Mxo85odR1smtX2eZ45JHmXsewmTOTVauakqO3N7n44vayAgAAAADAGVLVJz99NFL/0Kp6MsmzdV3/zkkfeyXJX9V1/dNf8/fOTrJ37969mT179ohnG7cOH062bStFxxtvdM6vu65sc9x/fzJtWisxAQAAAABgJO3bty9z5sxJkjl1Xe/7rs8d8U2PqqqmJrkryb/42mhjkkXf8PnTkpz8O/SzRjpTV9i5M1m7tnx/6tRkyZJmk6Ovryk9AAAAAABgAhuN563OSzIpyUdf+/hHSS78hs//aZI/HIUc3WXBguT225tv+/qSlSuTs89uOxUAAAAAAIwZo3bIPMnX382qvuFjSfLHSX520vdnJXlvtEKNW5MmJc8913YKAAAAAAAYs0aj9PgkyfH86lbH/Pzq9kfquj6c5PDw96uqGoVIAAAAAABAt+sZ6X9gXddHkjyTZNXXRquSbB/pHw8AAAAAACAZveetfpbkv6+qakeSx5P8dpLLk/w3o/TjAQAAAAAAE9yolB51Xf+PVVXNS/JfJbkoyYtJeuu6fns0fjwAAAAAAIBRO2Re1/W/SfJvRuufDwAAAAAAcLIRv+kBAAAAAADQBqUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQegAAAAAAAF1B6QEAAAAAAHQFpQcAAAAAANAVlB4AAAAAAEBXUHoAAAAAAABdYXLbAb7Nvn372o4AAAAAAAC07FT6gqqu61GMcuqqqrokyXtt5wAAAAAAAMaUS+u6fv+7PmEslh5VkouT7G87yxg0K00hdGn83wfONF9/0B5ff9AuX4PQHl9/0B5ff9AuX4N8k1lJPqh/Takx5p63OhH4O5uaiarpg5Ik++u69v4XnEG+/qA9vv6gXb4GoT2+/qA9vv6gXb4G+Rbf678LDpkDAAAAAABdQekBAAAAAAB0BaXH+HI4yf/hxLfAmeXrD9rj6w/a5WsQ2uPrD9rj6w/a5WuQ0zbmDpkDAAAAAACcDpseAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQeowTVVX9blVVb1ZVdaiqqmeqqrq/7UwwEVRV9dOqqp6uqmp/VVV7qqr6q6qqbmg7F0xEJ74e66qq/qTtLDARVFV1SVVV/8+qqj6tqupgVVXPVVV1V9u5YCKoqmpyVVX/pxO/Bvyqqqo3qqr6r6qq8mt4GGFVVT1QVdV/qqrqgxM/1/yffW1eVVX1RyfmX1VVta2qqltaigtd5bu+/qqqmlJV1b+squqFqqoOnPic/66qqotbjMw44SdM40BVVb+Z5E+S/LMkdyZ5JMn6qqoubzMXTBBLkvxZkoVJViWZnGRjVVUzW00FE0xVVQuS/HaSn7edBSaCqqrmJnksydEk65LcnOR/n+SLFmPBRPL7Sf43Sf7LJDcl+b0k/yjJ/7bNUNClZiZ5Ps3X2zf5vST/8MR8QZLdSTZVVTXrzMSDrvZdX38zkvw4yT898e3fSnJ9kv/vGUvHuFXVdd12Bn6NqqqeTPJsXde/c9LHXknyV3Vd/7S9ZDDxVFV1fpI9SZbUdf1w23lgIqiq6uwkzyb53ST/JMlzdV3/g1ZDQZerqupfJLmvrmvbxdCCqqr+f0k+quv6f3XSx/4yycG6rv+X7SWD7lZVVZ3kN+q6/qsT36+SfJDkT+q6/pcnPjYtyUdJfr+u63/bVlboNl//+vuWz1mQ5KkkV9R1/c6Zysb4Y9NjjKuqamqSu5Js/NpoY5JFZz4RTHhzTnz7WaspYGL5syT9dV0/2HYQmED+RpIdVVX9hxPPO+6squp/3XYomEAeTbKiqqrrk6SqqtuTLE4y0GoqmHiuSnJhTvo9mbquDyd5KH5PBtowJ0kd28f8GpPbDsCvdV6SSWn+FMHJPkrzL17gDDnxp3x+luTRuq5fbDsPTARVVf3tNKvMC9rOAhPM1Ul+J82/9/55knuS/N+qqjpc1/V/12oymBj+ZZrf2PlFVVXH0/ya8B/Xdf3v240FE87w77t80+/JXHGGs8CEVlXV9CT/Ism/q+t6X9t5GNuUHuPH198hq77hY8Do+tdJbkvzp+yAUVZV1WVJ/q9JVtd1fajtPDDB9CTZUdf1H5z4/s4TR1t/J4nSA0bfbyb5XyT5O0leSnJHkj+pquqDuq7/os1gMEH5PRloUVVVU5L8D2l+jvq7LcdhHFB6jH2fJDmeX93qmJ9f/ZMGwCipqupP0zz18UBd1++1nQcmiLvS/PvumWbRKknzJ10fqKrqv0wyra7r422Fgy73YZKXv/axV5L8z1vIAhPR/znJv6jr+n848f0Xqqq6IslPkyg94MzZfeLbC9P8u3GY35OBM+RE4fH/TvPc3HJbHnwfbnqMcXVdH0nyTJJVXxutSrL9zCeCiaVq/OskfyvNv1zfbDsTTCCbk/wozZ9uHf5rR5L/V5I7FB4wqh5LcsPXPnZ9krdbyAIT0YwkQ1/72PH4NTycaW+mKT7++vdkTtxeXRK/JwOj7qTC47okK+u6/rTlSIwTNj3Gh58l+e+rqtqR5PEkv53k8iT/TaupYGL4szTPCvzNJPurqhreutpb1/VX7cWC7lfX9f4kHfdzqqo6kORTd3Vg1P1fkmyvquoP0vxC8540Pwf97VZTwcTxn5L846qq3knzvNWdSf5hkv9Hq6mgC1VVdXaSa0/60FVVVd2R5LO6rt+pqupPkvxBVVWvJXktyR8kOZjk353prNBtvuvrL8kHSf4/aW48/mdJJp30ezKfnfiD4vCNqrr2BOF4UFXV7yb5vSQXpfkNoP9dXdcPt5sKul9VVd/2P5K/Vdf1n5/JLEBSVdW2JM/Vdf0PWo4CXa+qqv8syR+n+ZN1byb5WV3X//d2U8HEUFXVrCT/NMlvpHlG54Mk/z7J/9Fv8sDIqqpqaZKt3zD6i7qu/17VvLP6h0n+iyRzkzyZ5O/7Qzjww33X11+SP0rzc9Bvsqyu622jEoquoPQAAAAAAAC6gvdAAQAAAACArqD0AAAAAAAAuoLSAwAAAAAA6ApKDwAAAAAAoCsoPQAAAAAAgK6g9AAAAAAAALqC0gMAAAAAAOgKSg8AAAAAAKArKD0AAAAAAICuoPQAAAAAAAC6gtIDAAAAAADoCv9/1MXeuKac8eEAAAAASUVORK5CYII=\n",
+ "text/plain": [
+ "<Figure size 2000x600 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "do_plot(is_sin = False)"
+ ]
+},'
+# rubocop:enable Layout/LineLength
+
+base = '{
+ "cells": [
+ <<>>{
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}'
+
+SMALL_NOTEBOOK = base.gsub('<<>>', large_cell)
+LARGE_NOTEBOOK = base.gsub('<<>>', Array.new(100, large_cell).join("\n"))
+
+puts "Small Notebook: #{SMALL_NOTEBOOK.bytesize}"
+puts "Large Notebook: #{LARGE_NOTEBOOK.bytesize}"
+
+def cases(benchmark_runner)
+ benchmark_runner.report('small_notebook') { IpynbDiff.transform(SMALL_NOTEBOOK) }
+ benchmark_runner.report('large_notebook') { IpynbDiff.transform(LARGE_NOTEBOOK) }
+end
+
+Benchmark.benchmark { |x| cases(x) }
+Benchmark.memory { |x| cases(x) }
diff --git a/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb b/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb
new file mode 100644
index 00000000000..66fb7af66af
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff/symbol_map_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require_relative '../test_helper'
+
+describe IpynbDiff::SymbolMap do
+ def res(*cases)
+ cases&.to_h || []
+ end
+
+ describe '.parse' do
+ subject { described_class.parse(JSON.pretty_generate(source)) }
+
+ context 'when object has blank key' do
+ let(:source) { { "": { "": 5 } } }
+
+ it { is_expected.to match_array(res([".", 2], ["..", 3])) }
+ end
+
+ context 'when object is empty' do
+ let(:source) { {} }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when object is empty array' do
+ let(:source) { [] }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when object has inner object and number' do
+ let(:source) { { obj1: { obj2: 1 } } }
+
+ it { is_expected.to match_array(res(['.obj1', 2], ['.obj1.obj2', 3])) }
+ end
+
+ context 'when object has inner object and number, string and array with object' do
+ let(:source) { { obj1: { obj2: [123, 2, true], obj3: "hel\nlo", obj4: true, obj5: 123, obj6: 'a' } } }
+
+ it do
+ is_expected.to match_array(
+ res(['.obj1', 2],
+ ['.obj1.obj2', 3],
+ ['.obj1.obj2.0', 4],
+ ['.obj1.obj2.1', 5],
+ ['.obj1.obj2.2', 6],
+ ['.obj1.obj3', 8],
+ ['.obj1.obj4', 9],
+ ['.obj1.obj5', 10],
+ ['.obj1.obj6', 11])
+ )
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb b/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb
new file mode 100644
index 00000000000..214a8192542
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff/transformer_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require_relative '../test_helper'
+
+describe IpynbDiff::Transformer do
+ describe '.transform' do
+ using RSpec::Parameterized::TableSyntax
+
+ let!(:default_config) { { include_frontmatter: false, hide_images: false } }
+
+ let(:test_case) { read_test_case(test_case_name) }
+ let(:notebook) { test_case[:input] || FROM_IPYNB }
+ let(:config) { {} }
+
+ subject { described_class.new(**default_config.merge(config)).transform(notebook) }
+
+ where(:ctx, :test_case_name, :config) do
+ 'renders metadata' | 'no_cells' | { include_frontmatter: true }
+ 'is empty for no cells, but metadata is false' | 'no_cells_no_metadata' | {}
+ 'adds markdown cell' | 'only_md' | {}
+ 'adds block with only one line of markdown' | 'single_line_md' | {}
+ 'adds raw block' | 'only_raw' | {}
+ 'code cell, but no output' | 'only_code' | {}
+ 'code cell, but no language' | 'only_code_no_language' | {}
+ 'code cell, but no kernelspec' | 'only_code_no_kernelspec' | {}
+ 'code cell, but no nb metadata' | 'only_code_no_metadata' | {}
+ 'text output' | 'text_output' | {}
+ 'ignores html output' | 'ignore_html_output' | {}
+ 'extracts png output along with text' | 'text_png_output' | {}
+ 'embeds svg as image' | 'svg' | {}
+ 'extracts latex output' | 'latex_output' | {}
+ 'extracts error output' | 'error_output' | {}
+ 'does not fetch tags if there is no cell metadata' | 'no_metadata_on_cell' | {}
+ 'generates :percent decorator' | 'percent_decorator' | {}
+ 'parses stream output' | 'stream_text' | {}
+ 'ignores unknown output type' | 'unknown_output_type' | {}
+ 'handles backslash correctly' | 'backslash_as_last_char' | {}
+ 'multiline png output' | 'multiline_png_output' | {}
+ 'hides images when option passed' | 'hide_images' | { hide_images: true }
+ '\n within source lines' | 'source_with_linebreak' | { hide_images: true }
+ end
+
+ with_them do
+ it 'generates the expected markdown' do
+ expect(subject.as_text).to eq test_case[:expected_markdown]
+ end
+
+ it 'marks the lines correctly' do
+ blocks = subject.blocks.map { |b| b[:source_symbol] }.join("\n")
+
+ expect(blocks).to eq test_case[:expected_symbols]
+ end
+ end
+
+ describe 'Source line map' do
+ let(:config) { { include_frontmatter: false } }
+ let(:test_case_name) { 'text_png_output' }
+
+ it 'generates the correct transformed to source line map' do
+ line_numbers = subject.blocks.map { |b| b[:source_line] }.join("\n")
+
+ expect(line_numbers).to eq test_case[:expected_line_numbers]
+ end
+ end
+
+ context 'when json is invalid' do
+ let(:notebook) { 'a' }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+
+ context 'when it does not have the cell tag' do
+ let(:notebook) { '{"metadata":[]}' }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+
+ context 'when notebook can not be parsed' do
+ let(:notebook) { '{"cells":[]}' }
+
+ before do
+ allow(Oj::Parser.usual).to receive(:parse).and_return(nil)
+ end
+
+ it 'raises error' do
+ expect { subject }.to raise_error(IpynbDiff::InvalidNotebookError)
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/ipynb_diff_spec.rb b/gems/ipynbdiff/spec/ipynb_diff_spec.rb
new file mode 100644
index 00000000000..44fcd99f131
--- /dev/null
+++ b/gems/ipynbdiff/spec/ipynb_diff_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require_relative 'test_helper'
+
+describe IpynbDiff do
+ def diff_signs(diff)
+ diff.to_s(:text).scan(/.*\n/).map { |l| l[0] }.join('') # rubocop:disable Rails/Pluck
+ end
+
+ describe '.diff' do
+ let(:from_path) { FROM_PATH }
+ let(:to_path) { TO_PATH }
+ let(:from) { File.read(from_path) }
+ let(:to) { File.read(to_path) }
+ let(:include_frontmatter) { false }
+ let(:hide_images) { false }
+
+ subject { described_class.diff(from, to, include_frontmatter: include_frontmatter, hide_images: hide_images) }
+
+ context 'if preprocessing is active' do
+ it 'html tables are stripped' do
+ is_expected.not_to include('<td>')
+ end
+ end
+
+ context 'when to is nil' do
+ let(:to) { nil }
+ let(:from_path) { test_case_input_path('only_md') }
+
+ it 'all lines are removals' do
+ expect(diff_signs(subject)).to eq('-----')
+ end
+ end
+
+ context 'when from is nil' do
+ let(:from) { nil }
+ let(:to_path) { test_case_input_path('only_md') }
+
+ it 'all lines are additions' do
+ expect(diff_signs(subject)).to eq('+++++')
+ end
+ end
+
+ context 'when include_frontmatter is true' do
+ let(:include_frontmatter) { true }
+
+ it 'shows changes metadata in the metadata' do
+ expect(subject.to_s(:text)).to include('+ display_name: New Python 3 (ipykernel)')
+ end
+ end
+
+ context 'when hide_images is true' do
+ let(:hide_images) { true }
+
+ it 'hides images' do
+ expect(subject.to_s(:text)).to include(' [Hidden Image Output]')
+ end
+ end
+
+ context 'when include_frontmatter is false' do
+ it 'drops metadata from the diff' do
+ expect(subject.to_s(:text)).not_to include('+ display_name: New Python 3 (ipykernel)')
+ end
+ end
+
+ context 'when either notebook can not be processed' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ctx, :from, :to) do
+ 'because from is invalid' | 'a' | nil
+ 'because from does not have the cell tag' | '{"metadata":[]}' | nil
+ 'because to is invalid' | nil | 'a'
+ 'because to does not have the cell tag' | nil | '{"metadata":[]}'
+ end
+
+ with_them do
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
+ describe '.transform' do
+ let(:notebook) { FROM_IPYNB }
+ let(:include_frontmatter) { false }
+ let(:hide_images) { false }
+
+ subject do
+ described_class.transform(notebook,
+ include_frontmatter: include_frontmatter,
+ hide_images: hide_images)
+ end
+
+ describe 'error cases' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ctx, :notebook) do
+ 'notebook is nil' | nil
+ 'notebook is invalid' | 'a'
+ 'notebook does not have cell' | '{"metadata":[]}'
+ end
+
+ with_them do
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe 'options' do
+ context 'when include_frontmatter is false' do
+ it { is_expected.not_to include('display_name: Python 3 (ipykernel)') }
+ end
+
+ context 'when include_frontmatter is true' do
+ let(:include_frontmatter) { true }
+
+ it { is_expected.to include('display_name: Python 3 (ipykernel)') }
+ end
+
+ context 'when hide_images is false' do
+ it { is_expected.not_to include('[Hidden Image Output]') }
+ end
+
+ context 'when hide_images is true' do
+ let(:hide_images) { true }
+
+ it { is_expected.to include(' [Hidden Image Output]') }
+ end
+ end
+ end
+end
diff --git a/gems/ipynbdiff/spec/test_helper.rb b/gems/ipynbdiff/spec/test_helper.rb
new file mode 100644
index 00000000000..626b72b99f0
--- /dev/null
+++ b/gems/ipynbdiff/spec/test_helper.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'simplecov'
+SimpleCov.start
+
+require 'ipynb_diff'
+require 'rspec'
+require 'rspec-parameterized'
+require 'json'
+
+BASE_PATH = File.join(__dir__ || '', 'testdata')
+
+FROM_PATH = File.join(BASE_PATH, 'from.ipynb')
+TO_PATH = File.join(BASE_PATH, 'to.ipynb')
+
+FROM_IPYNB = File.read(FROM_PATH)
+TO_IPYNB = File.read(TO_PATH)
+
+def test_case_input_path(test_case)
+ File.join(BASE_PATH, test_case, 'input.ipynb')
+end
+
+def test_case_symbols_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected_symbols.txt')
+end
+
+def test_case_md_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected.md')
+end
+
+def test_case_line_numbers_path(test_case)
+ File.join(BASE_PATH, test_case, 'expected_line_numbers.txt')
+end
+
+def read_file_if_exists(path)
+ File.read(path) if File.file?(path)
+end
+
+def read_test_case(test_case_name)
+ {
+ input: read_file_if_exists(test_case_input_path(test_case_name)),
+ expected_markdown: read_file_if_exists(test_case_md_path(test_case_name)),
+ expected_symbols: read_file_if_exists(test_case_symbols_path(test_case_name)),
+ expected_line_numbers: read_file_if_exists(test_case_line_numbers_path(test_case_name))
+ }
+end
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md
new file mode 100644
index 00000000000..299e286c679
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected.md
@@ -0,0 +1,7 @@
+%% Cell type:markdown id: tags:
+
+\
+
+%% Cell type:markdown id: tags:
+
+a
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt
new file mode 100644
index 00000000000..6fa29ae28de
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/expected_symbols.txt
@@ -0,0 +1,7 @@
+.cells.0
+
+.cells.0.source.0
+
+.cells.1
+
+.cells.1.source.0
diff --git a/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb
new file mode 100644
index 00000000000..0714044e3ae
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/backslash_as_last_char/input.ipynb
@@ -0,0 +1,16 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "source": [
+ "\\"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "source": [
+ "a"
+ ]
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/error_output/expected.md b/gems/ipynbdiff/spec/testdata/error_output/expected.md
new file mode 100644
index 00000000000..e6e8a075598
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/expected.md
@@ -0,0 +1,16 @@
+%% Cell type:code id:5 tags:
+
+``` python
+# A cell that has an error
+y = sin(x)
+```
+
+%% Output
+
+ ---------------------------------------------------------------------------
+ NameError Traceback (most recent call last)
+ /var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_72857/3962062127.py in <module>
+ 1 # A cell that has an error
+ ----> 2 y = sin(x)
+
+ NameError: name 'sin' is not defined
diff --git a/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt
new file mode 100644
index 00000000000..5d2f248135d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/expected_symbols.txt
@@ -0,0 +1,16 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.traceback.0
+.cells.0.outputs.0.traceback.1
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.2
+.cells.0.outputs.0.traceback.3
diff --git a/gems/ipynbdiff/spec/testdata/error_output/input.ipynb b/gems/ipynbdiff/spec/testdata/error_output/input.ipynb
new file mode 100644
index 00000000000..45ee81a0e2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/error_output/input.ipynb
@@ -0,0 +1,32 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "5",
+ "metadata": {},
+ "outputs": [
+ {
+ "ename": "NameError",
+ "evalue": "name 'sin' is not defined",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_72857/3962062127.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# A cell that has an error\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0my\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msin\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mx\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+ "\u001b[0;31mNameError\u001b[0m: name 'sin' is not defined"
+ ]
+ }
+ ],
+ "source": [
+ "# A cell that has an error\n",
+ "y = sin(x)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/from.ipynb b/gems/ipynbdiff/spec/testdata/from.ipynb
new file mode 100644
index 00000000000..68a4b11cbbc
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/from.ipynb
@@ -0,0 +1,197 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0aac5da7-745c-4eda-847a-3d0d07a1bb9b",
+ "metadata": {
+ "tags": []
+ },
+ "source": [
+ "# This is a markdown cell\n",
+ "\n",
+ "This paragraph has\n",
+ "With\n",
+ "Many\n",
+ "Lines. How we will he handle MR notes?\n",
+ "\n",
+ "But I can add another paragraph"
+ ]
+ },
+ {
+ "cell_type": "raw",
+ "id": "faecea5b-de0a-49fa-9a3a-61c2add652da",
+ "metadata": {},
+ "source": [
+ "This is a raw cell\n",
+ "With\n",
+ "Multiple lines"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "893ca2c0-ab75-4276-9dad-be1c40e16e8a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "0d707fb5-226f-46d6-80bd-489ebfb8905c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "np.random.seed(42)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "35467fcf-28b1-4c7b-bb09-4cb192c35293",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ }, "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x123e39370>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "some_invalid_base64_image_here\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "dc1178cd-c46d-4da3-9ab5-08f000699884",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "df = pd.DataFrame({\"x\": x, \"y\": y})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "6e749b4f-b409-4700-870f-f68c39462490",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.253655</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.253655"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0ddef5ef-94a3-4afd-9c70-ddee9694f512",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/expected.md b/gems/ipynbdiff/spec/testdata/hide_images/expected.md
new file mode 100644
index 00000000000..ff63d351a3b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/expected.md
@@ -0,0 +1,10 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+```
+
+%% Output
+
+ [Hidden Image Output]
+
+ [Hidden Image Output]
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt
new file mode 100644
index 00000000000..b8f24f9fba5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/expected_symbols.txt
@@ -0,0 +1,10 @@
+.cells.0
+
+.cells.0.source
+
+
+.cells.0.outputs
+
+
+
+
diff --git a/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb b/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb
new file mode 100644
index 00000000000..dab0e5bb9cf
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/hide_images/input.ipynb
@@ -0,0 +1,45 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "this_is_an_invalid_hash_for_testing_purposes\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>",
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md
new file mode 100644
index 00000000000..3085da739ed
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected.md
@@ -0,0 +1,11 @@
+%% Cell type:code id:5 tags:some-table
+
+``` python
+df[:2]
+```
+
+%% Output
+
+ x y
+ 0 0.000000 0.000000
+ 1 0.256457 0.507309
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt
new file mode 100644
index 00000000000..3bf319d1fa6
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/expected_symbols.txt
@@ -0,0 +1,11 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
+.cells.0.outputs.0.data.text/plain.1
+.cells.0.outputs.0.data.text/plain.2
diff --git a/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb b/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb
new file mode 100644
index 00000000000..26117a78934
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/ignore_html_output/input.ipynb
@@ -0,0 +1,74 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.507309</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.507309"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/expected.md b/gems/ipynbdiff/spec/testdata/latex_output/expected.md
new file mode 100644
index 00000000000..194c1f43c42
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/expected.md
@@ -0,0 +1,10 @@
+%% Cell type:code id:5 tags:
+
+``` python
+from IPython.display import display, Math
+display(Math(r'Dims: {}x{}m \\ Area: {}m^2 \\ Volume: {}m^3'.format(1, round(2,2), 3, 4)))
+```
+
+%% Output
+
+ $\displaystyle Dims: 1x2m \\ Area: 3m^2 \\ Volume: 4m^3$
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt
new file mode 100644
index 00000000000..868adca2712
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/expected_symbols.txt
@@ -0,0 +1,10 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/latex.0
diff --git a/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb b/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb
new file mode 100644
index 00000000000..f8ff3e72beb
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/latex_output/input.ipynb
@@ -0,0 +1,34 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "5",
+ "outputs": [
+ {
+ "data": {
+ "text/latex": [
+ "$\\displaystyle Dims: 1x2m \\\\ Area: 3m^2 \\\\ Volume: 4m^3$"
+ ],
+ "text/plain": [
+ "<IPython.core.display.Math object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import display, Math\n",
+ "display(Math(r'Dims: {}x{}m \\\\ Area: {}m^2 \\\\ Volume: {}m^3'.format(1, round(2,2), 3, 4)))"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md
new file mode 100644
index 00000000000..0a69c8370e7
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:5 tags:
+
+```
+Some Image
+```
+
+%% Output
+
+ ![](data:image/png;base64,this_is_an_invalid_hash_for_testing_purposes)
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt
new file mode 100644
index 00000000000..1b66012ef20
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.image/png
diff --git a/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb b/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb
new file mode 100644
index 00000000000..4d19a504553
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/multiline_png_output/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "5",
+ "metadata": {
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": [
+ "this_is_an_invalid_hash_for_testing_purposes"
+ ]
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "Some Image"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/expected.md b/gems/ipynbdiff/spec/testdata/no_cells/expected.md
new file mode 100644
index 00000000000..b7c09c51fb8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/expected.md
@@ -0,0 +1,19 @@
+---
+jupyter:
+ kernelspec:
+ display_name: Python 3 (ipykernel)
+ language: python
+ name: python3
+ language_info:
+ codemirror_mode:
+ name: ipython
+ version: 3
+ file_extension: ".py"
+ mimetype: text/x-python
+ name: python
+ nbconvert_exporter: python
+ pygments_lexer: ipython3
+ version: 3.9.7
+ nbformat: 4
+ nbformat_minor: 5
+---
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt
new file mode 100644
index 00000000000..a60f3032882
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/expected_symbols.txt
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb b/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb
new file mode 100644
index 00000000000..c2ba0ebf50a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected.md
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/expected_symbols.txt
diff --git a/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb
new file mode 100644
index 00000000000..c2ba0ebf50a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_cells_no_metadata/input.ipynb
@@ -0,0 +1,25 @@
+{
+ "cells": [],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md
new file mode 100644
index 00000000000..d9d72bf8f76
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected.md
@@ -0,0 +1,13 @@
+%% Cell type:markdown id:1 tags:
+
+# A
+
+B
+
+%% Cell type:code id:3 tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt
new file mode 100644
index 00000000000..a7000494a1b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/expected_symbols.txt
@@ -0,0 +1,13 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+
+.cells.1
+
+.cells.1.source
+.cells.1.source.0
+.cells.1.source.1
+.cells.1.source.2
+
diff --git a/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb
new file mode 100644
index 00000000000..62060124a2a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/no_metadata_on_cell/input.ipynb
@@ -0,0 +1,29 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code/expected.md b/gems/ipynbdiff/spec/testdata/only_code/expected.md
new file mode 100644
index 00000000000..124b8217a6a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/expected.md
@@ -0,0 +1,7 @@
+%% Cell type:code id:3 tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt
new file mode 100644
index 00000000000..59b11103195
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/expected_symbols.txt
@@ -0,0 +1,7 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code/input.ipynb
new file mode 100644
index 00000000000..a93108dccb8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code/input.ipynb
@@ -0,0 +1,21 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb
new file mode 100644
index 00000000000..c3ff71057a6
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_kernelspec/input.ipynb
@@ -0,0 +1,12 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ],
+ "metadata": {}
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb
new file mode 100644
index 00000000000..fb16b106cbe
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_language/input.ipynb
@@ -0,0 +1,14 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {}
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md
new file mode 100644
index 00000000000..c6d8e13fc3a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:code id:3 tags:
+
+```
+```
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt
new file mode 100644
index 00000000000..2e902582e14
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source
+
diff --git a/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb
new file mode 100644
index 00000000000..364c080168b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_code_no_metadata/input.ipynb
@@ -0,0 +1,11 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3",
+ "source": "",
+ "outputs": []
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_md/expected.md b/gems/ipynbdiff/spec/testdata/only_md/expected.md
new file mode 100644
index 00000000000..bdf4db5aea5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:markdown id:1 tags:hello,world
+
+# A
+
+B
diff --git a/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt
new file mode 100644
index 00000000000..d3d6d526fc3
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
diff --git a/gems/ipynbdiff/spec/testdata/only_md/input.ipynb b/gems/ipynbdiff/spec/testdata/only_md/input.ipynb
new file mode 100644
index 00000000000..9d6b550af31
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_md/input.ipynb
@@ -0,0 +1,21 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/expected.md b/gems/ipynbdiff/spec/testdata/only_raw/expected.md
new file mode 100644
index 00000000000..91c476e843b
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/expected.md
@@ -0,0 +1,4 @@
+%% Cell type:raw id:2 tags:
+
+A
+B
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt
new file mode 100644
index 00000000000..bceaf355c2f
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/expected_symbols.txt
@@ -0,0 +1,4 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
diff --git a/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb b/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb
new file mode 100644
index 00000000000..750e1bba615
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/only_raw/input.ipynb
@@ -0,0 +1,15 @@
+{
+ "cells": [
+ {
+ "cell_type": "raw",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "A\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md b/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md
new file mode 100644
index 00000000000..1ece1f2fd06
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/percent_decorator/expected.md
@@ -0,0 +1,68 @@
+%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:
+
+# This is a markdown cell
+
+This paragraph has
+With
+Many
+Lines. How we will he handle MR notes?
+
+But I can add another paragraph
+
+%% Cell type:raw id:faecea5b-de0a-49fa-9a3a-61c2add652da tags:
+
+This is a raw cell
+With
+Multiple lines
+
+%% Cell type:code id:893ca2c0-ab75-4276-9dad-be1c40e16e8a tags:
+
+``` python
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+```
+
+%% Cell type:code id:0d707fb5-226f-46d6-80bd-489ebfb8905c tags:
+
+``` python
+np.random.seed(42)
+```
+
+%% Cell type:code id:35467fcf-28b1-4c7b-bb09-4cb192c35293 tags:senoid
+
+``` python
+x = np.linspace(0, 4*np.pi,50)
+y = np.sin(x)
+
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x123e39370>]
+
+ ![](data:image/png;base64,some_invalid_base64_image_here)
+
+%% Cell type:code id:dc1178cd-c46d-4da3-9ab5-08f000699884 tags:
+
+``` python
+df = pd.DataFrame({"x": x, "y": y})
+```
+
+%% Cell type:code id:6e749b4f-b409-4700-870f-f68c39462490 tags:some-table
+
+``` python
+df[:2]
+```
+
+%% Output
+
+ x y
+ 0 0.000000 0.000000
+ 1 0.256457 0.253655
+
+%% Cell type:code id:0ddef5ef-94a3-4afd-9c70-ddee9694f512 tags:
+
+``` python
+```
diff --git a/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt
new file mode 100644
index 00000000000..c95665d1903
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/percent_decorator/expected_symbols.txt
@@ -0,0 +1,68 @@
+.cells.0
+
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+.cells.0.source.4
+.cells.0.source.5
+.cells.0.source.6
+.cells.0.source.7
+
+.cells.1
+
+.cells.1.source.0
+.cells.1.source.1
+.cells.1.source.2
+
+.cells.2
+
+.cells.2.source
+.cells.2.source.0
+.cells.2.source.1
+.cells.2.source.2
+
+
+.cells.3
+
+.cells.3.source
+.cells.3.source.0
+
+
+.cells.4
+
+.cells.4.source
+.cells.4.source.0
+.cells.4.source.1
+.cells.4.source.2
+.cells.4.source.3
+
+
+.cells.4.outputs
+
+.cells.4.outputs.0.data.text/plain.0
+
+.cells.4.outputs.1.data.image/png
+
+.cells.5
+
+.cells.5.source
+.cells.5.source.0
+
+
+.cells.6
+
+.cells.6.source
+.cells.6.source.0
+
+
+.cells.6.outputs
+
+.cells.6.outputs.0.data.text/plain.0
+.cells.6.outputs.0.data.text/plain.1
+.cells.6.outputs.0.data.text/plain.2
+
+.cells.7
+
+.cells.7.source
+
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/expected.md b/gems/ipynbdiff/spec/testdata/single_line_md/expected.md
new file mode 100644
index 00000000000..392a5048f59
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/expected.md
@@ -0,0 +1,3 @@
+%% Cell type:markdown id:1 tags:hello,world
+
+A
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt
new file mode 100644
index 00000000000..86a7f6b3960
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/expected_symbols.txt
@@ -0,0 +1,3 @@
+.cells.0
+
+.cells.0.source
diff --git a/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb b/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb
new file mode 100644
index 00000000000..5ebd41adbfa
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/single_line_md/input.ipynb
@@ -0,0 +1,17 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": "A"
+ }
+ ],
+ "metadata": {
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md
new file mode 100644
index 00000000000..180fffe24ce
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:markdown id: tags:
+
+> This is a test
+>
+> To see if I can duplicate my bug
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt
new file mode 100644
index 00000000000..1e8bdda4b9a
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source
+.cells.0.source
diff --git a/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb b/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb
new file mode 100644
index 00000000000..faacc703969
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/source_with_linebreak/input.ipynb
@@ -0,0 +1,11 @@
+{
+ "metadata": {
+ },
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "source": "> This is a test\n>\n> To see if I can duplicate my bug",
+ "metadata": {}
+ }
+ ]
+}
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/expected.md b/gems/ipynbdiff/spec/testdata/stream_text/expected.md
new file mode 100644
index 00000000000..0448bf21111
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:123 tags:
+
+``` python
+print("G'bye")
+```
+
+%% Output
+
+ G'bye
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt
new file mode 100644
index 00000000000..be4e2861ea9
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.text.0
diff --git a/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb b/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb
new file mode 100644
index 00000000000..14601fe35e5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/stream_text/input.ipynb
@@ -0,0 +1,27 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "123",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "G'bye\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"G'bye\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/svg/expected.md b/gems/ipynbdiff/spec/testdata/svg/expected.md
new file mode 100644
index 00000000000..a5a167d31c5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/expected.md
@@ -0,0 +1,17 @@
+%% Cell type:code id:5 tags:
+
+``` python
+from IPython.display import SVG, display
+
+svg = """<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="50" cy="50" r="50"/>
+</svg>"""
+
+display(SVG(svg))
+```
+
+%% Output
+
+ ![](data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50"/></svg>)
+
+ ![](data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50"/></svg>)
diff --git a/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt
new file mode 100644
index 00000000000..861198a8c92
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/expected_symbols.txt
@@ -0,0 +1,17 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+.cells.0.source.4
+.cells.0.source.5
+.cells.0.source.6
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.image/svg+xml
+
+.cells.0.outputs.1.data.image/svg+xml
diff --git a/gems/ipynbdiff/spec/testdata/svg/input.ipynb b/gems/ipynbdiff/spec/testdata/svg/input.ipynb
new file mode 100644
index 00000000000..a02d01f7bf2
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/svg/input.ipynb
@@ -0,0 +1,66 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "5",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\">\n",
+ " <circle cx=\"50\" cy=\"50\" r=\"50\"/>\n",
+ "</svg>"
+ ],
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>",
+ "text/plain": [
+ "<IPython.core.display.SVG object>"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import SVG, display\n",
+ "\n",
+ "svg = \"\"\"<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\">\n",
+ " <circle cx=\"50\" cy=\"50\" r=\"50\"/>\n",
+ "</svg>\"\"\"\n",
+ "\n",
+ "display(SVG(svg))"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.8.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/text_output/expected.md b/gems/ipynbdiff/spec/testdata/text_output/expected.md
new file mode 100644
index 00000000000..1b6c086ecd5
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/expected.md
@@ -0,0 +1,9 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x12a4e43d0>]
diff --git a/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt
new file mode 100644
index 00000000000..a004d852ba4
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/expected_symbols.txt
@@ -0,0 +1,9 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
diff --git a/gems/ipynbdiff/spec/testdata/text_output/input.ipynb b/gems/ipynbdiff/spec/testdata/text_output/input.ipynb
new file mode 100644
index 00000000000..b1b387bb99d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_output/input.ipynb
@@ -0,0 +1,31 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "plt.plot(x, y)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected.md b/gems/ipynbdiff/spec/testdata/text_png_output/expected.md
new file mode 100644
index 00000000000..c77f109378c
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected.md
@@ -0,0 +1,14 @@
+%% Cell type:code id:5 tags:senoid
+
+``` python
+x = np.linspace(0, 4*np.pi,50)
+y = 2 * np.sin(x)
+
+plt.plot(x, y)
+```
+
+%% Output
+
+ [<matplotlib.lines.Line2D at 0x12a4e43d0>]
+
+ ![](data:image/png;base64,this_is_an_invalid_hash_for_testing_purposes)
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt b/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
new file mode 100644
index 00000000000..62e35deb96d
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
@@ -0,0 +1,14 @@
+3
+
+36
+37
+38
+39
+40
+
+
+12
+
+16
+
+25
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt
new file mode 100644
index 00000000000..49f2d7149d8
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/expected_symbols.txt
@@ -0,0 +1,14 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+.cells.0.source.1
+.cells.0.source.2
+.cells.0.source.3
+
+
+.cells.0.outputs
+
+.cells.0.outputs.0.data.text/plain.0
+
+.cells.0.outputs.1.data.image/png
diff --git a/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb b/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb
new file mode 100644
index 00000000000..3728b129d26
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/text_png_output/input.ipynb
@@ -0,0 +1,49 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "5",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "this_is_an_invalid_hash_for_testing_purposes\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = 2 * np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}
diff --git a/gems/ipynbdiff/spec/testdata/to.ipynb b/gems/ipynbdiff/spec/testdata/to.ipynb
new file mode 100644
index 00000000000..99b51f3b857
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/to.ipynb
@@ -0,0 +1,200 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0aac5da7-745c-4eda-847a-3d0d07a1bb9b",
+ "metadata": {
+ "tags": []
+ },
+ "source": [
+ "# This is a markdown cell\n",
+ "\n",
+ "This paragraph has\n",
+ "With\n",
+ "Many\n",
+ "Lines. How we will he handle MR notes?\n",
+ "\n",
+ "But I can add another paragraph\n",
+ "\n",
+ "Another paragraph added"
+ ]
+ },
+ {
+ "cell_type": "raw",
+ "id": "faecea5b-de0a-49fa-9a3a-61c2add652da",
+ "metadata": {},
+ "source": [
+ "This is a raw cell\n",
+ "With\n",
+ "Multiple lines"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "893ca2c0-ab75-4276-9dad-be1c40e16e8a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "0d707fb5-226f-46d6-80bd-489ebfb8905c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "np.random.seed(42)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "35467fcf-28b1-4c7b-bb09-4cb192c35293",
+ "metadata": {
+ "tags": [
+ "senoid"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[<matplotlib.lines.Line2D at 0x12a4e43d0>]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "another_invalid_base64_image_here\n",
+ "text/plain": [
+ "<Figure size 432x288 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "x = np.linspace(0, 4*np.pi,50)\n",
+ "y = 2 * np.sin(x)\n",
+ "\n",
+ "plt.plot(x, y)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "dc1178cd-c46d-4da3-9ab5-08f000699884",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "df = pd.DataFrame({\"x\": x, \"y\": y})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "6e749b4f-b409-4700-870f-f68c39462490",
+ "metadata": {
+ "tags": [
+ "some-table"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "<div>\n",
+ "<style scoped>\n",
+ " .dataframe tbody tr th:only-of-type {\n",
+ " vertical-align: middle;\n",
+ " }\n",
+ "\n",
+ " .dataframe tbody tr th {\n",
+ " vertical-align: top;\n",
+ " }\n",
+ "\n",
+ " .dataframe thead th {\n",
+ " text-align: right;\n",
+ " }\n",
+ "</style>\n",
+ "<table border=\"1\" class=\"dataframe\">\n",
+ " <thead>\n",
+ " <tr style=\"text-align: right;\">\n",
+ " <th></th>\n",
+ " <th>x</th>\n",
+ " <th>y</th>\n",
+ " </tr>\n",
+ " </thead>\n",
+ " <tbody>\n",
+ " <tr>\n",
+ " <th>0</th>\n",
+ " <td>0.000000</td>\n",
+ " <td>0.000000</td>\n",
+ " </tr>\n",
+ " <tr>\n",
+ " <th>1</th>\n",
+ " <td>0.256457</td>\n",
+ " <td>0.507309</td>\n",
+ " </tr>\n",
+ " </tbody>\n",
+ "</table>\n",
+ "</div>"
+ ],
+ "text/plain": [
+ " x y\n",
+ "0 0.000000 0.000000\n",
+ "1 0.256457 0.507309"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "df[:2]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0ddef5ef-94a3-4afd-9c70-ddee9694f512",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "New Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.7"
+ },
+ "toc-showtags": true
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md
new file mode 100644
index 00000000000..af34d6eb8c3
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected.md
@@ -0,0 +1,5 @@
+%% Cell type:code id:123 tags:
+
+``` python
+print("G'bye")
+```
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt
new file mode 100644
index 00000000000..cb35f88c897
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/expected_symbols.txt
@@ -0,0 +1,5 @@
+.cells.0
+
+.cells.0.source
+.cells.0.source.0
+
diff --git a/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb b/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb
new file mode 100644
index 00000000000..42f4b39b365
--- /dev/null
+++ b/gems/ipynbdiff/spec/testdata/unknown_output_type/input.ipynb
@@ -0,0 +1,27 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "123",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "unknown_output",
+ "text": [
+ "G'bye\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"G'bye\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "language": "python"
+ }
+ }
+}