public function getRelativePath(string $fromDir, string $to) : string
    {
        if (strpos($to, 'data://') === 0) {
            return $to;
        }

        list($fromStreamWrapper, $fromDirPath) = $this->splitStreamWrapperAndPath($fromDir);
        list($toStreamWrapper, $toPath) = $this->splitStreamWrapperAndPath($to);

        if ($fromStreamWrapper !== $toStreamWrapper) {
            return $to;
        }

        // some compatibility fixes for Windows paths
        $fromDirPath = str_replace('\\', '/', $fromDirPath);
        $toPath      = str_replace('\\', '/', $toPath);

        if (substr($fromDirPath, -1) !== '/') {
            $fromDirPath .= '/';
        }

        // Optimize common case where $to is a sub path of $from
        if (strpos($toPath, $fromDirPath) === 0) {
            return PathHelper::normalize(substr($toPath, strlen($fromDirPath)) ?: './');
        }

        $fromDirPath = explode('/', $fromDirPath);
        $toPath      = explode('/', $toPath);
        $relPath     = $toPath;

        foreach ($fromDirPath as $depth => $dir) {
            // find first non-matching dir
            if (isset($toPath[$depth]) && $dir === $toPath[$depth]) {
                // ignore this directory
                array_shift($relPath);
            } else {
                // get number of remaining dirs to $from
                $remaining = count($fromDirPath) - $depth;
                if ($remaining > 1) {
                    // add traversals up to first matching dir
                    $padLength = (count($relPath) + $remaining - 1) * -1;
                    $relPath   = array_pad($relPath, $padLength, '..');
                    break;
                } else {
                    $relPath[0] = './' . (isset($relPath[0]) ? $relPath[0] : '');
                }
            }
        }

        $relPath = implode('/', $relPath);

        return PathHelper::normalize(
            $relPath === '..' || $relPath === '.'
                ? $relPath . '/'
                : $relPath
        );
    }