diff --git a/composer.json b/composer.json
index 7590eade1999a3f953eeb1affb147ceb087c1eab..665529f54aa265c7088d7a2c76f5dc4f41ce835d 100644
--- a/composer.json
+++ b/composer.json
@@ -21,8 +21,10 @@
         "ext-iconv": "Can be used as fallback when ext-mbstring is not available"
     },
     "require-dev": {
-        "simpletest/simpletest": "^1.1",
-        "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.3 || ^9.4"
+        "composer": "^2.4",
+        "lolli42/finediff": "^1.0",
+        "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.3 || ^9.4",
+        "simpletest/simpletest": "^1.1"
     },
     "repositories": [
     ],
diff --git a/tests/legacy/finediff.php b/tests/legacy/finediff.php
deleted file mode 100644
index b2b559b190a2622919924d01f2f113a67373a718..0000000000000000000000000000000000000000
--- a/tests/legacy/finediff.php
+++ /dev/null
@@ -1,687 +0,0 @@
-<?php
-/**
-* FINE granularity DIFF
-*
-* Computes a set of instructions to convert the content of
-* one string into another.
-*
-* Copyright (c) 2011 Raymond Hill (http://raymondhill.net/blog/?p=441)
-*
-* Licensed under The MIT License
-*
-* 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.
-*
-* @copyright Copyright 2011 (c) Raymond Hill (http://raymondhill.net/blog/?p=441)
-* @link http://www.raymondhill.net/finediff/
-* @version 0.6
-* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
-*/
-
-/**
-* Usage (simplest):
-*
-*   include 'finediff.php';
-*
-*   // for the stock stack, granularity values are:
-*   // FineDiff::$paragraphGranularity = paragraph/line level
-*   // FineDiff::$sentenceGranularity = sentence level
-*   // FineDiff::$wordGranularity = word level
-*   // FineDiff::$characterGranularity = character level [default]
-*
-*   $opcodes = FineDiff::getDiffOpcodes($from_text, $to_text [, $granularityStack = null] );
-*   // store opcodes for later use...
-*
-*   ...
-*
-*   // restore $to_text from $from_text + $opcodes
-*   include 'finediff.php';
-*   $to_text = FineDiff::renderToTextFromOpcodes($from_text, $opcodes);
-*
-*   ...
-*/
-
-/**
-* Persisted opcodes (string) are a sequence of atomic opcode.
-* A single opcode can be one of the following:
-*   c | c{n} | d | d{n} | i:{c} | i{length}:{s}
-*   'c'        = copy one character from source
-*   'c{n}'     = copy n characters from source
-*   'd'        = skip one character from source
-*   'd{n}'     = skip n characters from source
-*   'i:{c}     = insert character 'c'
-*   'i{n}:{s}' = insert string s, which is of length n
-*
-* Do not exist as of now, under consideration:
-*   'm{n}:{o}  = move n characters from source o characters ahead.
-*   It would be essentially a shortcut for a delete->copy->insert
-*   command (swap) for when the inserted segment is exactly the same
-*   as the deleted one, and with only a copy operation in between.
-*   TODO: How often this case occurs? Is it worth it? Can only
-*   be done as a postprocessing method (->optimize()?)
-*/
-abstract class FineDiffOp {
-	abstract public function getFromLen();
-	abstract public function getToLen();
-	abstract public function getOpcode();
-	}
-
-class FineDiffDeleteOp extends FineDiffOp {
-	public function __construct($len) {
-		$this->fromLen = $len;
-		}
-	public function getFromLen() {
-		return $this->fromLen;
-		}
-	public function getToLen() {
-		return 0;
-		}
-	public function getOpcode() {
-		if ( $this->fromLen === 1 ) {
-			return 'd';
-			}
-		return "d{$this->fromLen}";
-		}
-	}
-
-class FineDiffInsertOp extends FineDiffOp {
-	public function __construct($text) {
-		$this->text = $text;
-		}
-	public function getFromLen() {
-		return 0;
-		}
-	public function getToLen() {
-		return strlen($this->text);
-		}
-	public function getText() {
-		return $this->text;
-		}
-	public function getOpcode() {
-		$to_len = strlen($this->text);
-		if ( $to_len === 1 ) {
-			return "i:{$this->text}";
-			}
-		return "i{$to_len}:{$this->text}";
-		}
-	}
-
-class FineDiffReplaceOp extends FineDiffOp {
-	public function __construct($fromLen, $text) {
-		$this->fromLen = $fromLen;
-		$this->text = $text;
-		}
-	public function getFromLen() {
-		return $this->fromLen;
-		}
-	public function getToLen() {
-		return strlen($this->text);
-		}
-	public function getText() {
-		return $this->text;
-		}
-	public function getOpcode() {
-		if ( $this->fromLen === 1 ) {
-			$del_opcode = 'd';
-			}
-		else {
-			$del_opcode = "d{$this->fromLen}";
-			}
-		$to_len = strlen($this->text);
-		if ( $to_len === 1 ) {
-			return "{$del_opcode}i:{$this->text}";
-			}
-		return "{$del_opcode}i{$to_len}:{$this->text}";
-		}
-	}
-
-class FineDiffCopyOp extends FineDiffOp {
-	public function __construct($len) {
-		$this->len = $len;
-		}
-	public function getFromLen() {
-		return $this->len;
-		}
-	public function getToLen() {
-		return $this->len;
-		}
-	public function getOpcode() {
-		if ( $this->len === 1 ) {
-			return 'c';
-			}
-		return "c{$this->len}";
-		}
-	public function increase($size) {
-		return $this->len += $size;
-		}
-	}
-
-/**
-* FineDiff ops
-*
-* Collection of ops
-*/
-class FineDiffOps {
-	public function appendOpcode($opcode, $from, $from_offset, $from_len) {
-		if ( $opcode === 'c' ) {
-			$edits[] = new FineDiffCopyOp($from_len);
-			}
-		else if ( $opcode === 'd' ) {
-			$edits[] = new FineDiffDeleteOp($from_len);
-			}
-		else /* if ( $opcode === 'i' ) */ {
-			$edits[] = new FineDiffInsertOp(substr($from, $from_offset, $from_len));
-			}
-		}
-	public $edits = array();
-	}
-
-/**
-* FineDiff class
-*
-* TODO: Document
-*
-*/
-class FineDiff {
-
-	/**------------------------------------------------------------------------
-	*
-	* Public section
-	*
-	*/
-
-	/**
-	* Constructor
-	* ...
-	* The $granularityStack allows FineDiff to be configurable so that
-	* a particular stack tailored to the specific content of a document can
-	* be passed.
-	*/
-	public function __construct($from_text = '', $to_text = '', $granularityStack = null) {
-		// setup stack for generic text documents by default
-		$this->granularityStack = $granularityStack ? $granularityStack : FineDiff::$characterGranularity;
-		$this->edits = array();
-		$this->from_text = $from_text;
-		$this->doDiff($from_text, $to_text);
-		}
-
-	public function getOps() {
-		return $this->edits;
-		}
-
-	public function getOpcodes() {
-		$opcodes = array();
-		foreach ( $this->edits as $edit ) {
-			$opcodes[] = $edit->getOpcode();
-			}
-		return implode('', $opcodes);
-		}
-
-	public function renderDiffToHTML() {
-		$in_offset = 0;
-		ob_start();
-		foreach ( $this->edits as $edit ) {
-			$n = $edit->getFromLen();
-			if ( $edit instanceof FineDiffCopyOp ) {
-				FineDiff::renderDiffToHTMLFromOpcode('c', $this->from_text, $in_offset, $n);
-				}
-			else if ( $edit instanceof FineDiffDeleteOp ) {
-				FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
-				}
-			else if ( $edit instanceof FineDiffInsertOp ) {
-				FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
-				}
-			else /* if ( $edit instanceof FineDiffReplaceOp ) */ {
-				FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
-				FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
-				}
-			$in_offset += $n;
-			}
-		return ob_get_clean();
-		}
-
-	/**------------------------------------------------------------------------
-	* Return an opcodes string describing the diff between a "From" and a
-	* "To" string
-	*/
-	public static function getDiffOpcodes($from, $to, $granularities = null) {
-		$diff = new FineDiff($from, $to, $granularities);
-		return $diff->getOpcodes();
-		}
-
-	/**------------------------------------------------------------------------
-	* Return an iterable collection of diff ops from an opcodes string
-	*/
-	public static function getDiffOpsFromOpcodes($opcodes) {
-		$diffops = new FineDiffOps();
-		FineDiff::renderFromOpcodes(null, $opcodes, array($diffops,'appendOpcode'));
-		return $diffops->edits;
-		}
-
-	/**------------------------------------------------------------------------
-	* Re-create the "To" string from the "From" string and an "Opcodes" string
-	*/
-	public static function renderToTextFromOpcodes($from, $opcodes) {
-		ob_start();
-		FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderToTextFromOpcode'));
-		return ob_get_clean();
-		}
-
-	/**------------------------------------------------------------------------
-	* Render the diff to an HTML string
-	*/
-	public static function renderDiffToHTMLFromOpcodes($from, $opcodes) {
-		ob_start();
-		FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderDiffToHTMLFromOpcode'));
-		return ob_get_clean();
-		}
-
-	/**------------------------------------------------------------------------
-	* Generic opcodes parser, user must supply callback for handling
-	* single opcode
-	*/
-	public static function renderFromOpcodes($from, $opcodes, $callback) {
-		if ( !is_callable($callback) ) {
-			return;
-			}
-		$opcodes_len = strlen($opcodes);
-		$from_offset = $opcodes_offset = 0;
-		while ( $opcodes_offset <  $opcodes_len ) {
-			$opcode = substr($opcodes, $opcodes_offset, 1);
-			$opcodes_offset++;
-			$n = intval(substr($opcodes, $opcodes_offset));
-			if ( $n ) {
-				$opcodes_offset += strlen(strval($n));
-				}
-			else {
-				$n = 1;
-				}
-			if ( $opcode === 'c' ) { // copy n characters from source
-				call_user_func($callback, 'c', $from, $from_offset, $n, '');
-				$from_offset += $n;
-				}
-			else if ( $opcode === 'd' ) { // delete n characters from source
-				call_user_func($callback, 'd', $from, $from_offset, $n, '');
-				$from_offset += $n;
-				}
-			else /* if ( $opcode === 'i' ) */ { // insert n characters from opcodes
-				call_user_func($callback, 'i', $opcodes, $opcodes_offset + 1, $n);
-				$opcodes_offset += 1 + $n;
-				}
-			}
-		}
-
-	/**
-	* Stock granularity stacks and delimiters
-	*/
-
-	const paragraphDelimiters = "\n\r";
-	public static $paragraphGranularity = array(
-		FineDiff::paragraphDelimiters
-		);
-	const sentenceDelimiters = ".\n\r";
-	public static $sentenceGranularity = array(
-		FineDiff::paragraphDelimiters,
-		FineDiff::sentenceDelimiters
-		);
-	const wordDelimiters = " \t.\n\r";
-	public static $wordGranularity = array(
-		FineDiff::paragraphDelimiters,
-		FineDiff::sentenceDelimiters,
-		FineDiff::wordDelimiters
-		);
-	const characterDelimiters = "";
-	public static $characterGranularity = array(
-		FineDiff::paragraphDelimiters,
-		FineDiff::sentenceDelimiters,
-		FineDiff::wordDelimiters,
-		FineDiff::characterDelimiters
-		);
-
-	public static $textStack = array(
-		".",
-		" \t.\n\r",
-		""
-		);
-
-	/**------------------------------------------------------------------------
-	*
-	* Private section
-	*
-	*/
-
-	/**
-	* Entry point to compute the diff.
-	*/
-	private function doDiff($from_text, $to_text) {
-		$this->last_edit = false;
-		$this->stackpointer = 0;
-		$this->from_text = $from_text;
-		$this->from_offset = 0;
-		// can't diff without at least one granularity specifier
-		if ( empty($this->granularityStack) ) {
-			return;
-			}
-		$this->_processGranularity($from_text, $to_text);
-		}
-
-	/**
-	* This is the recursive function which is responsible for
-	* handling/increasing granularity.
-	*
-	* Incrementally increasing the granularity is key to compute the
-	* overall diff in a very efficient way.
-	*/
-	private function _processGranularity($from_segment, $to_segment) {
-		$delimiters = $this->granularityStack[$this->stackpointer++];
-		$has_next_stage = $this->stackpointer < count($this->granularityStack);
-		foreach ( FineDiff::doFragmentDiff($from_segment, $to_segment, $delimiters) as $fragment_edit ) {
-			// increase granularity
-			if ( $fragment_edit instanceof fineDiffReplaceOp && $has_next_stage ) {
-				$this->_processGranularity(
-					substr($this->from_text, $this->from_offset, $fragment_edit->getFromLen()),
-					$fragment_edit->getText()
-					);
-				}
-			// fuse copy ops whenever possible
-			else if ( $fragment_edit instanceof fineDiffCopyOp && $this->last_edit instanceof fineDiffCopyOp ) {
-				$this->edits[count($this->edits)-1]->increase($fragment_edit->getFromLen());
-				$this->from_offset += $fragment_edit->getFromLen();
-				}
-			else {
-				/* $fragment_edit instanceof fineDiffCopyOp */
-				/* $fragment_edit instanceof fineDiffDeleteOp */
-				/* $fragment_edit instanceof fineDiffInsertOp */
-				$this->edits[] = $this->last_edit = $fragment_edit;
-				$this->from_offset += $fragment_edit->getFromLen();
-				}
-			}
-		$this->stackpointer--;
-		}
-
-	/**
-	* This is the core algorithm which actually perform the diff itself,
-	* fragmenting the strings as per specified delimiters.
-	*
-	* This function is naturally recursive, however for performance purpose
-	* a local job queue is used instead of outright recursivity.
-	*/
-	private static function doFragmentDiff($from_text, $to_text, $delimiters) {
-		// Empty delimiter means character-level diffing.
-		// In such case, use code path optimized for character-level
-		// diffing.
-		if ( empty($delimiters) ) {
-			return FineDiff::doCharDiff($from_text, $to_text);
-			}
-
-		$result = array();
-
-		// fragment-level diffing
-		$from_text_len = strlen($from_text);
-		$to_text_len = strlen($to_text);
-		$from_fragments = FineDiff::extractFragments($from_text, $delimiters);
-		$to_fragments = FineDiff::extractFragments($to_text, $delimiters);
-
-		$jobs = array(array(0, $from_text_len, 0, $to_text_len));
-
-		$cached_array_keys = array();
-
-		while ( $job = array_pop($jobs) ) {
-
-			// get the segments which must be diff'ed
-			list($from_segment_start, $from_segment_end, $to_segment_start, $to_segment_end) = $job;
-
-			// catch easy cases first
-			$from_segment_length = $from_segment_end - $from_segment_start;
-			$to_segment_length = $to_segment_end - $to_segment_start;
-			if ( !$from_segment_length || !$to_segment_length ) {
-				if ( $from_segment_length ) {
-					$result[$from_segment_start * 4] = new fineDiffDeleteOp($from_segment_length);
-					}
-				else if ( $to_segment_length ) {
-					$result[$from_segment_start * 4 + 1] = new fineDiffInsertOp(substr($to_text, $to_segment_start, $to_segment_length));
-					}
-				continue;
-				}
-
-			// find longest copy operation for the current segments
-			$best_copy_length = 0;
-
-			$from_base_fragment_index = $from_segment_start;
-
-			$cached_array_keys_for_current_segment = array();
-
-			while ( $from_base_fragment_index < $from_segment_end ) {
-				$from_base_fragment = $from_fragments[$from_base_fragment_index];
-				$from_base_fragment_length = strlen($from_base_fragment);
-				// performance boost: cache array keys
-				if ( !isset($cached_array_keys_for_current_segment[$from_base_fragment]) ) {
-					if ( !isset($cached_array_keys[$from_base_fragment]) ) {
-						$to_all_fragment_indices = $cached_array_keys[$from_base_fragment] = array_keys($to_fragments, $from_base_fragment, true);
-						}
-					else {
-						$to_all_fragment_indices = $cached_array_keys[$from_base_fragment];
-						}
-					// get only indices which falls within current segment
-					if ( $to_segment_start > 0 || $to_segment_end < $to_text_len ) {
-						$to_fragment_indices = array();
-						foreach ( $to_all_fragment_indices as $to_fragment_index ) {
-							if ( $to_fragment_index < $to_segment_start ) { continue; }
-							if ( $to_fragment_index >= $to_segment_end ) { break; }
-							$to_fragment_indices[] = $to_fragment_index;
-							}
-						$cached_array_keys_for_current_segment[$from_base_fragment] = $to_fragment_indices;
-						}
-					else {
-						$to_fragment_indices = $to_all_fragment_indices;
-						}
-					}
-				else {
-					$to_fragment_indices = $cached_array_keys_for_current_segment[$from_base_fragment];
-					}
-				// iterate through collected indices
-				foreach ( $to_fragment_indices as $to_base_fragment_index ) {
-					$fragment_index_offset = $from_base_fragment_length;
-					// iterate until no more match
-					for (;;) {
-						$fragment_from_index = $from_base_fragment_index + $fragment_index_offset;
-						if ( $fragment_from_index >= $from_segment_end ) {
-							break;
-							}
-						$fragment_to_index = $to_base_fragment_index + $fragment_index_offset;
-						if ( $fragment_to_index >= $to_segment_end ) {
-							break;
-							}
-						if ( $from_fragments[$fragment_from_index] !== $to_fragments[$fragment_to_index] ) {
-							break;
-							}
-						$fragment_length = strlen($from_fragments[$fragment_from_index]);
-						$fragment_index_offset += $fragment_length;
-						}
-					if ( $fragment_index_offset > $best_copy_length ) {
-						$best_copy_length = $fragment_index_offset;
-						$best_from_start = $from_base_fragment_index;
-						$best_to_start = $to_base_fragment_index;
-						}
-					}
-				$from_base_fragment_index += strlen($from_base_fragment);
-				// If match is larger than half segment size, no point trying to find better
-				// TODO: Really?
-				if ( $best_copy_length >= $from_segment_length / 2) {
-					break;
-					}
-				// no point to keep looking if what is left is less than
-				// current best match
-				if ( $from_base_fragment_index + $best_copy_length >= $from_segment_end ) {
-					break;
-					}
-				}
-
-			if ( $best_copy_length ) {
-				$jobs[] = array($from_segment_start, $best_from_start, $to_segment_start, $best_to_start);
-				$result[$best_from_start * 4 + 2] = new fineDiffCopyOp($best_copy_length);
-				$jobs[] = array($best_from_start + $best_copy_length, $from_segment_end, $best_to_start + $best_copy_length, $to_segment_end);
-				}
-			else {
-				$result[$from_segment_start * 4 ] = new fineDiffReplaceOp($from_segment_length, substr($to_text, $to_segment_start, $to_segment_length));
-				}
-			}
-
-		ksort($result, SORT_NUMERIC);
-		return array_values($result);
-		}
-
-	/**
-	* Perform a character-level diff.
-	*
-	* The algorithm is quite similar to doFragmentDiff(), except that
-	* the code path is optimized for character-level diff -- strpos() is
-	* used to find out the longest common subequence of characters.
-	*
-	* We try to find a match using the longest possible subsequence, which
-	* is at most the length of the shortest of the two strings, then incrementally
-	* reduce the size until a match is found.
-	*
-	* I still need to study more the performance of this function. It
-	* appears that for long strings, the generic doFragmentDiff() is more
-	* performant. For word-sized strings, doCharDiff() is somewhat more
-	* performant.
-	*/
-	private static function doCharDiff($from_text, $to_text) {
-		$result = array();
-		$jobs = array(array(0, strlen($from_text), 0, strlen($to_text)));
-		while ( $job = array_pop($jobs) ) {
-			// get the segments which must be diff'ed
-			list($from_segment_start, $from_segment_end, $to_segment_start, $to_segment_end) = $job;
-			$from_segment_len = $from_segment_end - $from_segment_start;
-			$to_segment_len = $to_segment_end - $to_segment_start;
-
-			// catch easy cases first
-			if ( !$from_segment_len || !$to_segment_len ) {
-				if ( $from_segment_len ) {
-					$result[$from_segment_start * 4 + 0] = new fineDiffDeleteOp($from_segment_len);
-					}
-				else if ( $to_segment_len ) {
-					$result[$from_segment_start * 4 + 1] = new fineDiffInsertOp(substr($to_text, $to_segment_start, $to_segment_len));
-					}
-				continue;
-				}
-			if ( $from_segment_len >= $to_segment_len ) {
-				$copy_len = $to_segment_len;
-				while ( $copy_len ) {
-					$to_copy_start = $to_segment_start;
-					$to_copy_start_max = $to_segment_end - $copy_len;
-					while ( $to_copy_start <= $to_copy_start_max ) {
-						$from_copy_start = strpos(substr($from_text, $from_segment_start, $from_segment_len), substr($to_text, $to_copy_start, $copy_len));
-						if ( $from_copy_start !== false ) {
-							$from_copy_start += $from_segment_start;
-							break 2;
-							}
-						$to_copy_start++;
-						}
-					$copy_len--;
-					}
-				}
-			else {
-				$copy_len = $from_segment_len;
-				while ( $copy_len ) {
-					$from_copy_start = $from_segment_start;
-					$from_copy_start_max = $from_segment_end - $copy_len;
-					while ( $from_copy_start <= $from_copy_start_max ) {
-						$to_copy_start = strpos(substr($to_text, $to_segment_start, $to_segment_len), substr($from_text, $from_copy_start, $copy_len));
-						if ( $to_copy_start !== false ) {
-							$to_copy_start += $to_segment_start;
-							break 2;
-							}
-						$from_copy_start++;
-						}
-					$copy_len--;
-					}
-				}
-			// match found
-			if ( $copy_len ) {
-				$jobs[] = array($from_segment_start, $from_copy_start, $to_segment_start, $to_copy_start);
-				$result[$from_copy_start * 4 + 2] = new FineDiffCopyOp($copy_len);
-				$jobs[] = array($from_copy_start + $copy_len, $from_segment_end, $to_copy_start + $copy_len, $to_segment_end);
-				}
-			// no match,  so delete all, insert all
-			else {
-				$result[$from_segment_start * 4] = new FineDiffReplaceOp($from_segment_len, substr($to_text, $to_segment_start, $to_segment_len));
-				}
-			}
-		ksort($result, SORT_NUMERIC);
-		return array_values($result);
-		}
-
-	/**
-	* Efficiently fragment the text into an array according to
-	* specified delimiters.
-	* No delimiters means fragment into single character.
-	* The array indices are the offset of the fragments into
-	* the input string.
-	* A sentinel empty fragment is always added at the end.
-	* Careful: No check is performed as to the validity of the
-	* delimiters.
-	*/
-	private static function extractFragments($text, $delimiters) {
-		// special case: split into characters
-		if ( empty($delimiters) ) {
-			$chars = str_split($text, 1);
-			$chars[strlen($text)] = '';
-			return $chars;
-			}
-		$fragments = array();
-		$start = $end = 0;
-		for (;;) {
-			$end += strcspn($text, $delimiters, $end);
-			$end += strspn($text, $delimiters, $end);
-			if ( $end === $start ) {
-				break;
-				}
-			$fragments[$start] = substr($text, $start, $end - $start);
-			$start = $end;
-			}
-		$fragments[$start] = '';
-		return $fragments;
-		}
-
-	/**
-	* Stock opcode renderers
-	*/
-	private static function renderToTextFromOpcode($opcode, $from, $from_offset, $from_len) {
-		if ( $opcode === 'c' || $opcode === 'i' ) {
-			echo substr($from, $from_offset, $from_len);
-			}
-		}
-
-	private static function renderDiffToHTMLFromOpcode($opcode, $from, $from_offset, $from_len) {
-		if ( $opcode === 'c' ) {
-			echo htmlentities(substr($from, $from_offset, $from_len));
-			}
-		else if ( $opcode === 'd' ) {
-			$deletion = substr($from, $from_offset, $from_len);
-			if ( strcspn($deletion, " \n\r") === 0 ) {
-				$deletion = str_replace(array("\n","\r"), array('\n','\r'), $deletion);
-				}
-			echo '<del>', htmlentities($deletion), '</del>';
-			}
-		else /* if ( $opcode === 'i' ) */ {
- 			echo '<ins>', htmlentities(substr($from, $from_offset, $from_len)), '</ins>';
-			}
-		}
-	}
diff --git a/tests/legacy/test_fonctions.php b/tests/legacy/test_fonctions.php
index 7831c38dbb0ffc711f65b4a569abbce2801a5148..b75d1b49ef013648bf6563545e7efd2fe7f0e5a3 100644
--- a/tests/legacy/test_fonctions.php
+++ b/tests/legacy/test_fonctions.php
@@ -1,5 +1,8 @@
 <?php
 
+// pour FineDiff
+include_once _SPIP_TEST_INC . '/vendor/autoload.php';
+
 function tests_init_dossier_squelettes() {
 	$GLOBALS['dossier_squelettes'] = _DIR_TESTS . 'tests/legacy/squelettes';
 }
@@ -89,19 +92,19 @@ function tester_fun($fun, $essais, $opts = array())
 	return $err;
 }
 
-function display_text_diff_callback($op, $texte, $offset, $nbchars, $z='') {
-	switch ($op) {
-		case 'c':
-			echo "  " . substr($texte, $offset, $nbchars);
-			break;
-		case 'd':
-			echo "- " . substr($texte, $offset, $nbchars);
-			break;
-		case 'i':
-			echo "+ " . substr($texte, $offset, $nbchars);
-			break;
+class SpipTestFineDiffRenderer extends \cogpowered\FineDiff\Render\Text {
+	public function callback($opcode, $from, $offset, $length): string
+	{
+		$content = substr($from, $offset, $length);
+		switch ($opcode) {
+			case 'c':
+				return '  ' . $content;
+			case 'd':
+				return '- ' . $content;
+			case 'i':
+				return '+ ' . $content;
+		}
 	}
-
 }
 
 function display_error($titre,$call,$result,$expected,$opts=array()){
@@ -109,20 +112,20 @@ function display_error($titre,$call,$result,$expected,$opts=array()){
 	static $style;
 	if (defined('_IS_CLI') and _IS_CLI){
 		echo "/!\ FAIL test `$titre`\n--- Expected\n+++ Actual\n@@ @@\n";
-		if (!class_exists("FineDiff")){
-			include_once _SPIP_TEST_INC . '/tests/legacy/finediff.php';
-		}
+
 		$from = var_export($expected, true);
-		$diff = new FineDiff($from, var_export($result, true), FineDiff::$paragraphGranularity);
-		$diff->renderFromOpcodes($from, $diff->getOpcodes(), 'display_text_diff_callback');
 
+		$FineDiff = new \cogpowered\FineDiff\Diff();
+		$FineDiff->setRenderer(new SpipTestFineDiffRenderer());
+		$FineDiff->setGranularity(new \cogpowered\FineDiff\Granularity\Paragraph());
+		echo $FineDiff->render($from, var_export($result, true));
 	} else {
 
 		if (!isset($bef)){
 			// options
 			foreach (array(
-				         'out' => '<dt>@</dt><dd class="ei">@</dd>'
-			         ) as $opt => $def){
+				'out' => '<dt>@</dt><dd class="ei">@</dd>'
+			) as $opt => $def) {
 				$$opt = isset($opts[$opt]) ? $opts[$opt] : $def;
 			}
 			// l'enrobage de sortie
@@ -142,14 +145,13 @@ function display_error($titre,$call,$result,$expected,$opts=array()){
 		.ei dt {font-weight: bold;font-size: 1.2em;}
 		.ei dd {margin-bottom: 1em;}
 		</style>";
-			if (!class_exists("FineDiff")){
-				include_once _SPIP_TEST_INC . '/tests/legacy/finediff.php';
-			}
 		} else {
 			$style = "";
 		}
 
-		$diff = new FineDiff(var_export($expected, true), var_export($result, true));
+		$FineDiff = new \cogpowered\FineDiff\Diff();
+		$FineDiff->setRenderer(new  \cogpowered\FineDiff\Render\Html());
+		$diff = $FineDiff->render(var_export($expected, true), var_export($result, true));
 
 		return
 			$style
@@ -158,7 +160,7 @@ function display_error($titre,$call,$result,$expected,$opts=array()){
 			. $mid
 			. "<pre>$call</pre>"
 			. "<table style='width:100%;'><tr><th>diff</th><th>attendu</th><th>resultat</th></tr><tr>"
-			. "<td><pre>" . ($affdiff ? $diff->renderDiffToHTML() : $affdiff) . "</pre></td>"
+			. "<td><pre>" . ($affdiff ? $diff : $affdiff) . "</pre></td>"
 			. '<td><pre>' . htmlspecialchars(var_export($expected, true)) . "</pre></td>"
 			. '<td><pre>' . htmlspecialchars(var_export($result, true)) . "</pre></td>"
 			. "</tr></table>"