public CharacterSource substitute(final int startIndex, final int endIndex,
            final CharSequence replacement) {
        checkRange(startIndex, endIndex);
        
        /* Create a SourceContext indicating that we've done a substitution */
        SubstitutionContext context = new SubstitutionContext(replacement);
        
        /* Make sure we are not trying to change the frozen part of the document */
        if (startIndex < freezeIndex) {
            throw new IllegalArgumentException("Cannot modify frozen part of document (startIndex=" + startIndex
                    + ",freezeIndex=" + freezeIndex
                    + ",attemptedText=" + buffer.substring(freezeIndex, Math.min(freezeIndex+20, length))
                    + ")");
        }
        
        /* Work out which components the start of the substitution lies in */
        IndexResolution startResolution = resolveIndex(startIndex, false);
        
        if (startResolution==null) {
            /* Special case - we are *appending* to the document, rather than substituting
             * existing content. This case is somewhat simpler to deal with so we will do it
             * all now.
             */
            CharacterSource toAppend = new CharacterSource(context);
            int newStartIndex = length;
            buffer.append(replacement);
            int newLength = buffer.length();
            Slice newEndSlice = new Slice(newStartIndex, newLength, toAppend, -newStartIndex);
            scoreBoard.add(newEndSlice);
            length = newLength;
            return toAppend;
        }
        
        /* If still here, then we're doing a genuine substitution (or insertion). The first
         * thing we do is modify the StringBuilder.
         */
        CharSequence beingReplaced = buffer.subSequence(startIndex, endIndex);
        buffer.delete(startIndex, endIndex);
        buffer.insert(startIndex, replacement);
        
        /* Next up, we have to rebuild the scoreboard. This is much more complicated so is best
         * demonstrated with some examples.
         * 
         * First note that the scoreboard will change from the slice containing to the startIndex
         * all the way to the end.
         * 
         * Let's assume for convenience that there are 4 entries in the scoreboard and that
         * the resolved startIndex lies within the entry with index 1 (i.e. the second entry).
         * 
         * [..............)[...............)[..................)[..............)
         * 
         * Expl 1:         [XXXXXXXXX) is being replaced with [YYYYYYY) to give
         * [..............)[YYYYYYY)[...)[..................)[..............)
         *     This stays     repl  left-   next component         similarly
         *     the same             over    has offset shifted
         *     
         * Here the replacement's left boundary sits nicely against an existing slice.
         *     
         * Expl 2:           [XXXXXXXXX) is being replaced with [YYYYYYY) to give
         * [..............)[)[YYYYYYY)[.)[..................)[..............)
         *   Stays same    cut repl   left   shift                shift
         *   
         * Here we need to create a slice out of the second component before continuing
         * in the same way.
         *   
         * Expl 3:         [XXXXXXXXXXXXXXXXXXXXX) is being replaced with [Y) to give
         * [..............)[Y)[..........)[..............)
         *   Stays same    repl  slice       shift left
         *   
         * Here, the replacement spans more than 1 slice. The original 3rd slice needs
         * to be cut down and shifted left. The 4th slice is just shifted left. Phew!!!
         */
        
        /* Anyway, in all cases we start off by keeping the slices that lie before the
         * one containing the start of the substitution.
         */
        int startSliceIndex = startResolution.scoreboardIndex;
        List<Slice> newScoreBoard = new ArrayList<Slice>();
        for (int i=0; i<startSliceIndex; i++) {
            newScoreBoard.add(scoreBoard.get(i));
        }
        
        /* Next up, we look at the slice containing the start of the substitution, making a
         * new slice to reflect "the bit before" if it is required. The offset of this component
         * does not change.
         */
        Slice startSlice = startResolution.slice;
        if (startIndex > startSlice.startIndex) {
            Slice bitBefore = new Slice(startSlice.startIndex, startIndex,
                    startSlice.resolvedComponent, startSlice.componentIndexOffset);
            newScoreBoard.add(bitBefore);
        }
        
        /* Now we create a slice corresponding to the substitution. The offset of this slice is
         * set to ensure that currentIndex corresponds to index 0 in the substitution - i.e.
         * shift left by currentIndex.
         */
        int currentIndex = startIndex;
        int substitutionSize = replacement.length();
        CharacterSource result = new CharacterSource(context, startResolution.slice.resolvedComponent,
                startResolution.indexInComponent, beingReplaced);
        Slice substitutionSlice = new Slice(currentIndex, currentIndex + substitutionSize, result, -currentIndex);
        currentIndex += substitutionSize;
        newScoreBoard.add(substitutionSlice);
        
        /* Next we need to find out where the end of the substitution is. All slices before this
         * one will be removed from the scoreboard. From the current end slice, we will re-slice
         * it if required to include whatever comes immediately after the substitution end point.
         * 
         * To calculate what happens to the offset, we look at 'endIndex' (before) and
         * 'currentIndex' (after). Both must map to the same point in the original character source.
         * Thus:
         * 
         * endIndex + before offset comp = currentIndex + after offset
         * 
         * i.e. after offset = before offset + before index in comp + endIndex - currentIndex;
         */
        IndexResolution endResolution = resolveIndex(endIndex, true);
        int afterEndIndex;
        if (endResolution!=null) { /* (Will be null if endIndex==0, i.e. insertion at start) */
            afterEndIndex = endResolution.scoreboardIndex + 1;
            Slice endSlice = endResolution.slice;
            if (endIndex < endSlice.endIndex) {
                int bitAfterSize = endSlice.endIndex - endIndex;
                int resultOffset = endSlice.componentIndexOffset + endIndex - currentIndex;
                Slice bitAfter = new Slice(currentIndex, currentIndex + bitAfterSize,
                        endSlice.resolvedComponent, resultOffset);
                newScoreBoard.add(bitAfter);
                currentIndex += bitAfterSize;
            }
        }
        else {
            afterEndIndex = 0;
        }
        
        /* Finally, we include shifted versions of all slices that followed the end index in the
         * original scoreboard. The change in offset is calculated by looking at the left
         * endpoint of the slice before and after shifted, which must map to the same point
         * in the original character source. Thus we have:
         * 
         * before startIndex + before offset = after startIndex + after offset
         * 
         * i.e. after offset = before offset + before startIndex - after startIndex
         */
        Slice trailingSlice, shiftedTrailingSlice;
        for (int i=afterEndIndex, size=scoreBoard.size(); i<size; i++) {
            trailingSlice = scoreBoard.get(i);
            int afterSliceLength = trailingSlice.endIndex - trailingSlice.startIndex;
            int shiftedOffset = trailingSlice.componentIndexOffset + trailingSlice.startIndex - currentIndex;
            shiftedTrailingSlice = new Slice(currentIndex, currentIndex + afterSliceLength,
                    trailingSlice.resolvedComponent, shiftedOffset);
            newScoreBoard.add(shiftedTrailingSlice);
            currentIndex += afterSliceLength;
        }
        
        /* Replace scoreboard */
        scoreBoard.clear();
        scoreBoard.addAll(newScoreBoard);
        
        /* Finally sync up lengths */
        if (buffer.length()!=currentIndex) {
            throw new SnuggleLogicException("Failed sanity check: buffer length is " + buffer.length() + ", last board index=" + currentIndex);
        }
        length = currentIndex;
        return result;
    }