/*******************************************************************************
* Copyright (c) 2004, 2008 John Krasnay and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* John Krasnay - initial API and implementation
*******************************************************************************/
package net.sf.vex.dom;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import net.sf.vex.core.ListenerList;
import net.sf.vex.undo.CannotRedoException;
import net.sf.vex.undo.CannotUndoException;
import net.sf.vex.undo.IUndoableEdit;
/**
* Represents an XML document.
*/
public class Document {
private Content content;
private RootElement rootElement;
private ListenerList listeners = new ListenerList(DocumentListener.class, DocumentEvent.class);
private boolean undoEnabled = true;
private String publicID;
private String systemID;
private String encoding;
private Validator validator;
/**
* Class constructor.
* @param rootElement root element of the document. The
* document property of this RootElement is set by this
* constructor.
*/
public Document(RootElement rootElement) {
this.content = new GapContent(100);
this.rootElement = rootElement;
rootElement.setDocument(this);
this.content.insertString(0, "\0\0");
rootElement.setContent(this.content, 0, 1);
}
/**
* Class constructor. This constructor is used by the document builder
* and assumes that the content and root element have bee properly set up.
*
* @param content Content object used to store the document's content.
* @param rootElement RootElement of the document.
*/
public Document(Content content, RootElement rootElement) {
this.content = content;
this.rootElement = rootElement;
}
/**
* Adds a document listener to the list of listeners to be notified of
* document changes.
*
* @param listener <code>DocumentListener</code> to add.
*/
public void addDocumentListener(DocumentListener listener) {
this.listeners.add(listener);
}
/**
* Returns true if the given document fragment can be inserted at the
* given offset.
*
* @param offset offset where the insertion is to occur
* @param fragment fragment to be inserted
*/
public boolean canInsertFragment(int offset, DocumentFragment fragment) {
if (this.validator == null) {
return true;
}
Element element = this.getElementAt(offset);
String[] seq1 = this.getNodeNames(element.getStartOffset() + 1, offset);
String[] seq2 = fragment.getNodeNames();
String[] seq3 = this.getNodeNames(offset, element.getEndOffset());
return this.validator.isValidSequence(
element.getName(),
seq1, seq2, seq3, true);
}
/**
* Returns true if text can be inserted at the
* given offset.
*
* @param offset offset where the insertion is to occur
*/
public boolean canInsertText(int offset) {
if (this.validator == null) {
return true;
}
Element element = this.getElementAt(offset);
String[] seq1 = this.getNodeNames(element.getStartOffset() + 1, offset);
String[] seq2 = new String[] { Validator.PCDATA };
String[] seq3 = this.getNodeNames(offset, element.getEndOffset());
return this.validator.isValidSequence(
element.getName(), seq1, seq2, seq3, true);
}
/**
* Creates a <code>Position</code> object at the given character offset.
*
* @param offset initial character offset of the position
*/
public Position createPosition(int offset) {
return this.content.createPosition(offset);
}
/**
* Deletes a portion of the document. No element may straddle the
* deletion span.
*
* @param startOffset start of the range to delete
* @param endOffset end of the range to delete
* @throws DocumentValidationException if the change would result
* in an invalid document.
*/
public void delete(int startOffset, int endOffset)
throws DocumentValidationException {
Element e1 = this.getElementAt(startOffset);
Element e2 = this.getElementAt(endOffset);
if (e1 != e2) {
throw new IllegalArgumentException("Deletion from " + startOffset + " to " + endOffset + " is unbalanced");
}
Validator validator = this.getValidator();
if (validator != null) {
String[] seq1 = this.getNodeNames(e1.getStartOffset() + 1, startOffset);
String[] seq2 = this.getNodeNames(endOffset, e1.getEndOffset());
if (!validator.isValidSequence(
e1.getName(), seq1, seq2, null, true)) {
throw new DocumentValidationException("Unable to delete from " + startOffset + " to " + endOffset);
}
}
// Grab the fragment for the undoable edit while it's still here
DocumentFragment frag = getFragment(startOffset, endOffset);
this.fireBeforeContentDeleted(
new DocumentEvent(this, e1, startOffset, endOffset - startOffset, null));
Iterator iter = e1.getChildIterator();
while (iter.hasNext()) {
Element child = (Element) iter.next();
if (startOffset <= child.getStartOffset() &&
child.getEndOffset() < endOffset) {
iter.remove();
}
}
this.content.remove(startOffset, endOffset - startOffset);
IUndoableEdit edit = this.undoEnabled ?
new DeleteEdit(startOffset, endOffset, frag) : null;
this.fireContentDeleted(
new DocumentEvent(this, e1, startOffset, endOffset - startOffset, edit));
}
/**
* Finds the lowest element that contains both of the given offsets.
*
* @param offset1 the first offset
* @param offset2 the second offset
*/
public Element findCommonElement(int offset1, int offset2) {
Element element = this.rootElement;
for (;;) {
boolean tryAgain = false;
Element[] children = element.getChildElements();
for (int i = 0; i < children.length; i++) {
if (offset1 > children[i].getStartOffset() &&
offset2 > children[i].getStartOffset() &&
offset1 <= children[i].getEndOffset() &&
offset2 <= children[i].getEndOffset()) {
element = children[i];
tryAgain = true;
break;
}
}
if (!tryAgain) {
break;
}
}
return element;
}
/**
* Returns the character at the given offset.
*/
public char getCharacterAt(int offset) {
return this.content.getString(offset, 1).charAt(0);
}
/**
* Returns the element at the given offset. The given offset must be
* greater or equal to 1 and less than the current document length.
*/
public Element getElementAt(int offset) {
if (offset < 1 || offset >= this.getLength()) {
throw new IllegalArgumentException("Illegal offset: " + offset + ". Must be between 1 and n-1");
}
Element element = this.rootElement;
for (;;) {
boolean tryAgain = false;
Element[] children = element.getChildElements();
for (int i = 0; i < children.length; i++) {
Element child = children[i];
if (offset <= child.getStartOffset()) {
return element;
} else if (offset <= child.getEndOffset()) {
element = child;
tryAgain = true;
break;
}
}
if (!tryAgain) {
break;
}
}
return element;
}
/**
* Returns the encoding used for this document, or null if no
* encoding has been declared.
*/
public String getEncoding() {
return this.encoding;
}
/**
* Create a <code>DocumentFragment</code> representing the given
* range of offsets.
*
* @return
*/
public DocumentFragment getFragment(int startOffset, int endOffset) {
assertOffset(startOffset, 0, this.content.getLength());
assertOffset(endOffset, 0, this.content.getLength());
if (endOffset <= startOffset) {
throw new IllegalArgumentException(
"Invalid range (" + startOffset + ", " + endOffset + ")");
}
Element e1 = this.getElementAt(startOffset);
Element e2 = this.getElementAt(endOffset);
if (e1 != e2) {
throw new IllegalArgumentException(
"Fragment from "
+ startOffset
+ " to "
+ endOffset
+ " is unbalanced");
}
Element[] children = e1.getChildElements();
Content newContent = new GapContent(endOffset - startOffset);
String s = this.content.getString(startOffset, endOffset - startOffset);
newContent.insertString(0, s);
List newChildren = new ArrayList();
for (int i = 0; i < children.length; i++) {
Element child = children[i];
if (child.getEndOffset() <= startOffset) {
continue;
} else if (child.getStartOffset() >= endOffset) {
break;
} else {
newChildren.add(
this.cloneElement(child, newContent, -startOffset, null));
}
}
Element[] elementArray =
(Element[]) newChildren.toArray(new Element[newChildren.size()]);
return new DocumentFragment(newContent, elementArray);
}
/**
* Returns the length of the document in characters, including the null
* characters that delimit each element.
*/
public int getLength() {
return this.content.getLength();
}
/**
* Returns an array of element names and Validator.PCDATA representing
* the content between the given offsets. The given offsets must both
* be directly in the same element.
*
* @param startOffset the offset at which the sequence begins
* @param endOffset the offset at which the sequence ends
*/
public String[] getNodeNames(int startOffset, int endOffset) {
Node[] nodes = this.getNodes(startOffset, endOffset);
String[] names = new String[nodes.length];
for (int i = 0; i < nodes.length; i++) {
Node node = nodes[i];
if (node instanceof Element) {
names[i] = ((Element)node).getName();
} else {
names[i] = Validator.PCDATA;
}
}
return names;
}
/**
* Returns an array of Nodes representing the selected range. The given offsets must both
* be directly in the same element.
*
* @param startOffset the offset at which the sequence begins
* @param endOffset the offset at which the sequence ends
*/
public Node[] getNodes(int startOffset, int endOffset) {
Element element = this.getElementAt(startOffset);
if (element != this.getElementAt(endOffset)) {
throw new IllegalArgumentException(
"Offsets are unbalanced: " +
startOffset + " is in " + element.getName() + ", " +
endOffset + " is in " + this.getElementAt(endOffset).getName());
}
List list = new ArrayList();
Node[] nodes = element.getChildNodes();
for (int i = 0; i < nodes.length; i++) {
Node node = nodes[i];
if (node.getEndOffset() <= startOffset) {
continue;
} else if (node.getStartOffset() >= endOffset) {
break;
} else {
if (node instanceof Element) {
list.add(node);
} else {
Text text = (Text) node;
if (text.getStartOffset() < startOffset) {
text.setContent(text.getContent(), startOffset, text.getEndOffset());
} else if (text.getEndOffset() > endOffset) {
text.setContent(text.getContent(), text.getStartOffset(), endOffset);
}
list.add(text);
}
}
}
return (Node[]) list.toArray(new Node[list.size()]);
}
/**
* Creates an array of nodes for a given run of content. The returned array includes the given child
* elements and <code>Text</code> objects where text appears between elements.
*
* @param content Content object containing the content
* @param startOffset start offset of the run
* @param endOffset end offset of the run
* @param elements child elements that are within the run
*/
static Node[] createNodeArray(Content content, int startOffset, int endOffset, Element[] elements) {
List nodes = new ArrayList();
int offset = startOffset;
for (int i = 0; i < elements.length; i++) {
int start = elements[i].getStartOffset();
if (offset < start) {
nodes.add(new Text(content, offset, start));
}
nodes.add(elements[i]);
offset = elements[i].getEndOffset() + 1;
}
if (offset < endOffset) {
nodes.add(new Text(content, offset, endOffset));
}
return (Node[]) nodes.toArray(new Node[nodes.size()]);
}
/**
* Returns the public ID of the document type.
*/
public String getPublicID() {
return this.publicID;
}
/**
* Returns the text between the two given offsets. Unlike getText,
* sentinel characters are not removed.
*
* @param startOffset character offset of the start of the text
* @param endOffset character offset of the end of the text
*/
public String getRawText(int startOffset, int endOffset) {
return this.content.getString(startOffset,
endOffset - startOffset);
}
/**
* Returns the root element of this document.
*/
public Element getRootElement() {
return this.rootElement;
}
/**
* Returns the system ID of the document type.
*/
public String getSystemID() {
return this.systemID;
}
/**
* Returns the text between the two given offsets. Sentinal characters
* are removed.
*
* @param startOffset character offset of the start of the text
* @param endOffset character offset of the end of the text
*/
public String getText(int startOffset, int endOffset) {
String raw = this.content.getString(startOffset,
endOffset - startOffset);
StringBuffer sb = new StringBuffer(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c != '\0') {
sb.append(c);
}
}
return sb.toString();
}
/**
* Returns the validator used to validate the document, or null if
* a validator has not been set. Note that the DocumentFactory
* does not automatically create a validator.
*/
public Validator getValidator() {
return this.validator;
}
/**
* Inserts an element at the given position.
*
* @param offset character offset at which the element is to be inserted.
* Must be greater or equal to 1 and less than the current length of the
* document, i.e. it must be within the range of the root element.
* @param element element to insert
* @throws DocumentValidationException if the change would result
* in an invalid document.
*/
public void insertElement(int offset, Element element)
throws DocumentValidationException {
if (offset < 1 || offset >= this.getLength()) {
throw new IllegalArgumentException("Error inserting element <" + element.getName() + ">: offset is " + offset + ", but it must be between 1 and " + (this.getLength() - 1));
}
Validator validator = this.getValidator();
if (validator != null) {
Element parent = this.getElementAt(offset);
String[] seq1 = this.getNodeNames(parent.getStartOffset() + 1, offset);
String[] seq2 = new String[] { element.getName() };
String[] seq3 = this.getNodeNames(offset, parent.getEndOffset());
if (!validator.isValidSequence(
parent.getName(), seq1, seq2, seq3, true)) {
throw new DocumentValidationException("Cannot insert element " + element.getName() + " at offset " + offset);
}
}
// find the parent, and the index into its children at which
// this element should be inserted
Element parent = this.rootElement;
int childIndex = -1;
while (childIndex == -1) {
boolean tryAgain = false;
Element[] children = parent.getChildElements();
for (int i = 0; i < children.length; i++) {
Element child = children[i];
if (offset <= child.getStartOffset()) {
childIndex = i;
break;
} else if (offset <= child.getEndOffset()) {
parent = child;
tryAgain = true;
break;
}
}
if (!tryAgain && childIndex == -1) {
childIndex = children.length;
break;
}
}
this.fireBeforeContentInserted(new DocumentEvent(this, parent, offset, 2, null));
this.content.insertString(offset, "\0\0");
element.setContent(this.content, offset, offset + 1);
element.setParent(parent);
parent.insertChild(childIndex, element);
IUndoableEdit edit = this.undoEnabled ?
new InsertElementEdit(offset, element) : null;
this.fireContentInserted(new DocumentEvent(this, parent, offset, 2, edit));
}
/**
* Inserts a document fragment at the given position.
*
* @param offset character offset at which the element is to be inserted.
* Must be greater or equal to 1 and less than the current length of the
* document, i.e. it must be within the range of the root element.
* @param fragment fragment to insert
* @throws DocumentValidationException if the change would result
* in an invalid document.
*/
public void insertFragment(int offset, DocumentFragment fragment)
throws DocumentValidationException {
if (offset < 1 || offset >= this.getLength()) {
throw new IllegalArgumentException("Error inserting document fragment");
}
Element parent = this.getElementAt(offset);
if (this.validator != null) {
String[] seq1 = this.getNodeNames(parent.getStartOffset() + 1, offset);
String[] seq2 = fragment.getNodeNames();
String[] seq3 = this.getNodeNames(offset, parent.getEndOffset());
if (!validator.isValidSequence(
parent.getName(), seq1, seq2, seq3, true)) {
throw new DocumentValidationException("Cannot insert document fragment");
}
}
this.fireBeforeContentInserted(new DocumentEvent(this, parent, offset, 2, null));
Content c = fragment.getContent();
String s = c.getString(0, c.getLength());
this.content.insertString(offset, s);
Element[] children = parent.getChildElements();
int index = 0;
while (index < children.length
&& children[index].getEndOffset() < offset) {
index++;
}
Element[] elements = fragment.getElements();
for (int i = 0; i < elements.length; i++) {
Element newElement = this.cloneElement(elements[i], this.content, offset, parent);
parent.insertChild(index, newElement);
index++;
}
IUndoableEdit edit = this.undoEnabled ?
new InsertFragmentEdit(offset, fragment) : null;
this.fireContentInserted(
new DocumentEvent(this, parent, offset, fragment.getContent().getLength(), edit));
}
/**
* Inserts text at the given position.
*
* @param offset character offset at which the text is to be inserted.
* Must be greater or equal to 1 and less than the current length of the
* document, i.e. it must be within the range of the root element.
* @param text text to insert
* @return UndoableEdit that can be used to undo the deletion
* @throws DocumentValidationException if the change would result
* in an invalid document.
*/
public void insertText(int offset, String text)
throws DocumentValidationException {
if (offset < 1 || offset >= this.getLength()) {
throw new IllegalArgumentException("Offset must be between 1 and n-1");
}
Element parent = this.getElementAt(offset);
boolean isValid = false;
if (this.getCharacterAt(offset-1) != '\0') {
isValid = true;
} else if (this.getCharacterAt(offset) != '\0') {
isValid = true;
} else {
Validator validator = this.getValidator();
if (validator != null) {
String[] seq1 = this.getNodeNames(parent.getStartOffset() + 1, offset);
String[] seq2 = new String[] { Validator.PCDATA };
String[] seq3 = this.getNodeNames(offset, parent.getEndOffset());
isValid = validator.isValidSequence(
parent.getName(), seq1, seq2, seq3, true);
} else {
isValid = true;
}
}
if (!isValid) {
throw new DocumentValidationException("Cannot insert text '" + text + "' at offset " + offset);
}
// Convert control chars to spaces
StringBuffer sb = new StringBuffer(text);
for (int i = 0; i < sb.length(); i++) {
if (Character.isISOControl(sb.charAt(i)) && sb.charAt(i) != '\n') {
sb.setCharAt(i, ' ');
}
}
String s = sb.toString();
this.fireBeforeContentInserted(new DocumentEvent(this, parent, offset, 2, null));
this.content.insertString(offset, s);
IUndoableEdit edit = this.undoEnabled ?
new InsertTextEdit(offset, s) : null;
this.fireContentInserted(
new DocumentEvent(this, parent, offset, s.length(), edit));
}
/**
* Returns true if undo is enabled, that is, undoable edit events are fired
* to registered listeners.
*/
public boolean isUndoEnabled() {
return this.undoEnabled;
}
/**
* Removes a document listener from the list of listeners so that
* it is no longer notified of document changes.
*
* @param listener <code>DocumentListener</code> to remove.
*/
public void removeDocumentListener(DocumentListener listener) {
this.listeners.remove(listener);
}
/**
* Sets the public ID for the document's document type.
*
* @param publicID New value for the public ID.
*/
public void setPublicID(String publicID) {
this.publicID = publicID;
}
/**
* Sets the system ID for the document's document type.
*
* @param systemID New value for the system ID.
*/
public void setSystemID(String systemID) {
this.systemID = systemID;
}
/**
* Sets whether undo events are enabled. Typically, undo events are
* disabled while an edit is being undone or redone.
*
* @param undoEnabled If true, undoable edit events are fired to
* registered listeners.
*/
public void setUndoEnabled(boolean undoEnabled) {
this.undoEnabled = undoEnabled;
}
/**
* Sets the validator to use for this document.
*
* @param validator Validator to use for this document.
*/
public void setValidator(Validator validator) {
this.validator = validator;
}
//==================================================== PRIVATE
/**
* Represents a deletion from a document that can be undone
* and redone.
*/
private class DeleteEdit implements IUndoableEdit {
private int startOffset;
private int endOffset;
private DocumentFragment frag;
public DeleteEdit(int startOffset, int endOffset, DocumentFragment frag) {
this.startOffset = startOffset;
this.endOffset = endOffset;
this.frag = frag;
}
public boolean combine(IUndoableEdit edit) {
return false;
}
public void undo() throws CannotUndoException {
try {
setUndoEnabled(false);
insertFragment(this.startOffset, this.frag);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
public void redo() throws CannotRedoException {
try {
setUndoEnabled(false);
delete(this.startOffset, this.endOffset);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
}
/**
* Represents an insertion of an element into the document.
*/
private class InsertElementEdit implements IUndoableEdit {
private int offset;
private Element element;
public InsertElementEdit(int offset, Element element) {
this.offset = offset;
this.element = element;
}
public boolean combine(IUndoableEdit edit) {
return false;
}
public void undo() throws CannotUndoException {
try {
setUndoEnabled(false);
delete(this.offset, this.offset + 2);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
public void redo() throws CannotRedoException {
try {
setUndoEnabled(false);
insertElement(this.offset, this.element);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
}
/**
* Represents an insertion of a fragment into the document.
*/
private class InsertFragmentEdit implements IUndoableEdit {
private int offset;
private DocumentFragment frag;
public InsertFragmentEdit(int offset, DocumentFragment frag) {
this.offset = offset;
this.frag = frag;
}
public boolean combine(IUndoableEdit edit) {
return false;
}
public void undo() throws CannotUndoException {
try {
setUndoEnabled(false);
int length = this.frag.getContent().getLength();
delete(this.offset, this.offset + length);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
public void redo() throws CannotRedoException {
try {
setUndoEnabled(false);
insertFragment(this.offset, this.frag);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
}
/**
* Represents an insertion of text into the document.
*/
private class InsertTextEdit implements IUndoableEdit {
private int offset;
private String text;
public InsertTextEdit(int offset, String text) {
this.offset = offset;
this.text = text;
}
public boolean combine(IUndoableEdit edit) {
if (edit instanceof InsertTextEdit) {
InsertTextEdit ite = (InsertTextEdit) edit;
if (ite.offset == this.offset + this.text.length()) {
this.text = this.text + ite.text;
return true;
}
}
return false;
}
public void undo() throws CannotUndoException {
try {
setUndoEnabled(false);
delete(this.offset, this.offset + this.text.length());
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
public void redo() throws CannotRedoException {
try {
setUndoEnabled(false);
insertText(this.offset, this.text);
} catch (DocumentValidationException ex) {
throw new CannotUndoException();
} finally {
setUndoEnabled(true);
}
}
}
/**
* Assert that the given offset is within the given range,
* throwing IllegalArgumentException if not.
*/
private static void assertOffset(int offset, int min, int max) {
if (offset < min || offset > max) {
throw new IllegalArgumentException("Bad offset " + offset +
"must be between " + min +
" and " + max);
}
}
/**
* Clone an element tree, pointing to a new Content object.
*
* @param original Element to be cloned
* @param content new Content object to which the clone will point
* @param shift amount to shift offsets to be valid in the new Content.
* @param parent parent for the cloned Element
*/
private Element cloneElement(
Element original,
Content content,
int shift,
Element parent) {
Element clone = new Element(original.getName());
clone.setContent(
content,
original.getStartOffset() + shift,
original.getEndOffset() + shift);
String[] attrNames = original.getAttributeNames();
for (int i = 0; i < attrNames.length; i++) {
try {
clone.setAttribute(attrNames[i], original.getAttribute(attrNames[i]));
} catch (DocumentValidationException ex) {
throw new RuntimeException("Unexpected exception: " + ex);
}
}
clone.setParent(parent);
Element[] children = original.getChildElements();
for (int i = 0; i < children.length; i++) {
Element cloneChild =
this.cloneElement(children[i], content, shift, clone);
clone.insertChild(i, cloneChild);
}
return clone;
}
void fireAttributeChanged(DocumentEvent e) {
this.listeners.fireEvent("attributeChanged", e);
}
private void fireBeforeContentDeleted(DocumentEvent e) {
this.listeners.fireEvent("beforeContentDeleted", e);
}
private void fireBeforeContentInserted(DocumentEvent e) {
this.listeners.fireEvent("beforeContentInserted", e);
}
private void fireContentDeleted(DocumentEvent e) {
this.listeners.fireEvent("contentDeleted", e);
}
private void fireContentInserted(DocumentEvent e) {
this.listeners.fireEvent("contentInserted", e);
}
}