/*
 * Copyright (c) 2017-2026 XMLmind Software. All rights reserved.
 *
 * Author: Hussein Shafie
 *
 * This file is part of the XMLmind Assembly Processor project.
 * For conditions of distribution and use, see the accompanying legal.txt file.
 */
package com.xmlmind.assembly;

import java.io.IOException;
import java.io.File;
import java.io.InputStream;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Map;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Stack;
import java.util.Arrays;
import java.util.Comparator;
import static javax.xml.XMLConstants.XML_NS_URI;
import javax.xml.namespace.QName;
import javax.xml.transform.URIResolver;
import javax.xml.transform.ErrorListener;
import javax.xml.transform.Transformer;
import javax.xml.transform.Templates;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.sax.SAXSource;
import org.xml.sax.InputSource;
import org.xml.sax.EntityResolver;
import org.xml.sax.XMLReader;
import org.w3c.dom.Node;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Text;
import org.w3c.dom.ProcessingInstruction;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Document;
import com.xmlmind.util.ThrowableUtil;
import com.xmlmind.util.ObjectUtil;
import com.xmlmind.util.ArrayUtil;
import com.xmlmind.util.StringList;
import com.xmlmind.util.StringUtil;
import com.xmlmind.util.FileUtil;
import com.xmlmind.util.URLUtil;
import com.xmlmind.util.XMLText;
import com.xmlmind.util.XMLResolver;
import com.xmlmind.util.Console;
import com.xmlmind.util.SimpleConsole;
import com.xmlmind.domutil.ConsoleHelper;
import com.xmlmind.domutil.DOMUtil;
import com.xmlmind.domutil.LocalizedText;
import com.xmlmind.domutil.XMLResolverImpl;
import com.xmlmind.domutil.Loader;
import com.xmlmind.domutil.LoaderImpl;
import com.xmlmind.domutil.SaveDocument;
import com.xmlmind.xinclude.XLoader;

/**
 * Assembly processor: processes a DocBook 5.1+ assembly file and 
 * creates the equivalent DocBook 5.1+ "flat" document 
 * (for example a <code>book</code>; called the "realized" document).
 */
public class Processor {
    /**
     * The version number of this processor.
     */
    public static final String VERSION = "2.0.5";

    protected static final String DEFAULT_DOCBOOK_VERSION = "5.2";
    
    /**
     * The namespace URI of DocBook v5+.
     */
    public static final String DOCBOOK_NS_URI = "http://docbook.org/ns/docbook";

    // -----------------------------------------------------------------------

    // ---------------------------------
    // Resource
    // ---------------------------------

    protected static class Resource {
        public final String id;
        public final URL url; // May have a fragment.
        public final String grammar; // May be null.
        public final String transform; // May be null.

        public Resource(String id, URL url, String grammar, String transform) {
            this.id = id;
            this.url = url;
            this.grammar = grammar;
            this.transform = transform;
        }

        @Override
        public String toString() {
            StringBuilder buffer = new StringBuilder("<resource");
            if (id != null) {
                buffer.append(" xml:id=");
                XMLText.quoteXML(id, buffer);
            }
            if (url != null) {
                buffer.append(" href=");
                XMLText.quoteXML(url.toExternalForm(), buffer);
            }
            if (grammar != null) {
                buffer.append(" grammar=");
                XMLText.quoteXML(grammar, buffer);
            }
            if (transform != null) {
                buffer.append(" transform=");
                XMLText.quoteXML(transform, buffer);
            }
            buffer.append("/>");
            return buffer.toString();
        }
    }

    // ---------------------------------
    // Transform
    // ---------------------------------

    protected static class Transform {
        // One of name and grammar, but not both, may be null.
        public final String name; 
        public final String grammar; 
        public final URL url;

        public Object impl;

        public Transform(String name, String grammar, URL url) {
            this.name = name;
            this.grammar = grammar;
            this.url = url;
        }

        @Override
        public String toString() {
            StringBuilder buffer = new StringBuilder("<transform");
            if (name != null) {
                buffer.append(" name=");
                XMLText.quoteXML(name, buffer);
            }
            if (grammar != null) {
                buffer.append(" grammar=");
                XMLText.quoteXML(grammar, buffer);
            }
            if (url != null) {
                buffer.append(" href=");
                XMLText.quoteXML(url.toExternalForm(), buffer);
            }
            buffer.append("/>");
            return buffer.toString();
        }
    }

    // ---------------------------------
    // CompareByAssociation
    // ---------------------------------

    protected static class CompareByAssociation
                     implements Comparator<Relationship> {
        public int compare(Relationship r1, Relationship r2) {
            return r1.association.compareTo(r2.association);
        }
    }

    protected static final CompareByAssociation COMPARE_BY_ASSOCIATION = 
        new CompareByAssociation();

    // ---------------------------------
    // Output
    // ---------------------------------

    protected static final class Output {
        public Boolean chunk; // Null means: auto
        public String file;
        public QName renderAs;
        public boolean suppress;
        public Transform[] transforms;
    }

    // ---------------------------------
    // Filter
    // ---------------------------------

    protected static final class Filter {
        public final String name;
        public final String[] values;
        public final boolean isFilterOut;

        public Filter() {
            this(null, null, true);
        }

        public Filter(String name, String[] values, boolean isFilterOut) {
            this.name = name;
            this.values = values;
            this.isFilterOut = isFilterOut;
        }

        public boolean isUnconditionalFilterOut() {
            return (isFilterOut && name == null);
        }
    }

    protected static final Filter[] NO_FILTERS = new Filter[0];

    // ---------------------------------
    // FilterAttribute
    // ---------------------------------

    protected static final class FilterAttribute {
        public final String name;
        public String[] inValues;
        public String[] outValues;

        public FilterAttribute(String name) {
            this.name = name;
            inValues = outValues = StringUtil.EMPTY_LIST;
        }

        public void merge(Filter filter) {
            if (filter.isFilterOut) {
                outValues = add(outValues, filter.values);
                inValues = remove(inValues, filter.values);
            } else {
                inValues = add(inValues, filter.values);
                outValues = remove(outValues, filter.values);
            }
        }

        private static String[] add(String[] list1, String[] list2) {
            if (list1 == null) {
                list1 = StringUtil.EMPTY_LIST;
            } 
            if (list2 == null) {
                list2 = StringUtil.EMPTY_LIST;
            }

            String[] result = new String[list1.length + list2.length];
            int j = 0;

            for (String item : list1) {
                // No duplicates.
                boolean found = false;
                for (int k = 0; k < j; ++k) {
                    if (item.equals(result[k])) {
                        found = true;
                        break;
                    }
                }

                if (!found) {
                    result[j++] = item;
                }
            }

            for (String item : list2) {
                boolean found = false;
                for (int k = 0; k < j; ++k) {
                    if (item.equals(result[k])) {
                        found = true;
                        break;
                    }
                }

                if (!found) {
                    result[j++] = item;
                }
            }

            if (j == 0) {
                result = StringUtil.EMPTY_LIST;
            } else if (j != result.length) {
                result = ArrayUtil.trimToSize(result, j);
            }

            return result;
        }

        private static String[] remove(String[] list1, String[] list2) {
            if (list1 == null) {
                list1 = StringUtil.EMPTY_LIST;
            } 
            if (list2 == null) {
                list2 = StringUtil.EMPTY_LIST;
            }

            String[] result = new String[list1.length];
            int j = 0;

            for (String item : list1) {
                if (ArrayUtil.indexOf(list2, item) >= 0) {
                    continue;
                }

                // No duplicates.
                boolean found = false;
                for (int k = 0; k < j; ++k) {
                    if (item.equals(result[k])) {
                        found = true;
                        break;
                    }
                }

                if (!found) {
                    result[j++] = item;
                }
            }

            if (j == 0) {
                result = StringUtil.EMPTY_LIST;
            } else if (j != result.length) {
                result = ArrayUtil.trimToSize(result, j);
            }

            return result;
        }
    }

    protected static final FilterAttribute[] NO_FILTER_ATTRIBUTES =
        new FilterAttribute[0];

    // ---------------------------------
    // FilterStack
    // ---------------------------------

    protected static final class FilterStack {
        private Stack<Filter[]> stack;

        private Boolean unconditionalFilterOut;
        private FilterAttribute[] filterAttributes;

        public FilterStack() {
            stack = new Stack<Filter[]>();
        }

        public void push() {
            stack.push(NO_FILTERS);
        }

        public Filter[] top() {
            return stack.peek();
        }

        public void top(Filter[] filters) {
            stack.setElementAt(filters, stack.size()-1);

            if (filters != NO_FILTERS) {
                unconditionalFilterOut = null;
                filterAttributes = null;
            }
        }

        public void pop() {
            if (stack.peek() != NO_FILTERS) {
                unconditionalFilterOut = null;
                filterAttributes = null;
            }

            stack.pop();
        }

        public boolean isUnconditionalFilterOut() {
            if (unconditionalFilterOut == null) {
                boolean filterOut = false;
                for (Filter[] filters : stack) {
                    for (Filter filter : filters) {
                        filterOut = filter.isUnconditionalFilterOut();
                    }
                }
                unconditionalFilterOut = 
                    filterOut? Boolean.TRUE : Boolean.FALSE;
            }

            return (unconditionalFilterOut == Boolean.TRUE);
        }

        public FilterAttribute[] getFilterAttributes() {
            if (filterAttributes == null) {
                boolean isUnconditionalFilterOut = false;
                HashMap<String,FilterAttribute> all = 
                    new HashMap<String,FilterAttribute>();

                for (Filter[] filters : stack) {
                    for (Filter filter : filters) {
                        isUnconditionalFilterOut = 
                            filter.isUnconditionalFilterOut();
                        if (!isUnconditionalFilterOut) {
                            FilterAttribute filterAttr = all.get(filter.name);
                            if (filterAttr == null) {
                                filterAttr = new FilterAttribute(filter.name);
                                all.put(filter.name, filterAttr);
                            }
                            filterAttr.merge(filter);
                        }
                    }
                }

                if (isUnconditionalFilterOut) {
                    filterAttributes = NO_FILTER_ATTRIBUTES;
                } else {
                    filterAttributes =
                        all.values().toArray(NO_FILTER_ATTRIBUTES);
                }
            }

            return filterAttributes;
        }
    }

    // -----------------------------------------------------------------------

    protected ConsoleHelper console;
    protected XMLResolver resolver;
    protected Loader documentLoader;
    protected com.xmlmind.transproc.Processor transclusionProcessor;
    protected String processedStructId;
    protected String[] outputFormats;
    protected boolean checkRealizedDocument;
    protected String[] profilingAttrs;

    protected HashMap<String,Element> idToStructure;
    protected HashMap<String,Resource> idToResource;
    protected HashMap<String,Transform> nameToTransform;
    protected HashMap<String,Transform> grammarToTransform;

    protected ArrayList<Relationship> relationshipList;
    protected HashMap<String,Element> idToRealized;

    // -----------------------------------------------------------------------
    // API
    // -----------------------------------------------------------------------

    /**
     * Equivalent to {@link #Processor Processor(null)}.
     */
    public Processor() {
        this(null);
    }

    /**
     * Constructs a Processor using specified console to display 
     * its progress, warning, error, etc, messages.
     *
     * @param c the console. May be <code>null</code>.
     * @see #setConsole
     */
    @SuppressWarnings("this-escape")
    public Processor(Console c) {
        setConsole(c);
        setDocumentLoader(null);
        setTransclusionProcessor(null);
        setCheckRealizedDocument(false);
        setProfile(null);
    }

    public void setConsole(Console c) {
        if (c == null) {
            c = new SimpleConsole();
        }
        console = ((c instanceof ConsoleHelper)? 
                   (ConsoleHelper) c : new ConsoleHelper(c));

        if (documentLoader != null) {
            documentLoader.setConsole(console);
        }
        if (transclusionProcessor != null) {
            transclusionProcessor.setConsole(console);
        }
    }

    public ConsoleHelper getConsole() {
        return console;
    }

    /**
     * Specifies which entity and URI resolver to use when 
     * loading or transforming an XML document.
     * 
     * @param resolver which resolver to use. May be <code>null</code>.
     * @see #getResolver
     */
    public void setResolver(XMLResolver resolver) {
        this.resolver = resolver;

        documentLoader.setEntityResolver(getEntityResolver());
    }

    /**
     * Returns the entity and URI resolver being used when 
     * loading or transforming an XML document. 
     * May return <code>null</code>.
     *
     * @see #setResolver
     */
    public XMLResolver getResolver() {
        return resolver;
    }

    /**
     * Specifies which document loader to use to load XML documents.
     * <p>This loader is expected to process XInclude elements.
     *
     * @param loader which loader to use. May be <code>null</code>, in which
     * case an instance of {@link LoaderImpl} is used.
     * @see #getDocumentLoader
     */
    public void setDocumentLoader(Loader loader) {
        if (loader == null) {
            loader = createDocumentLoader();
        }
        loader.setEntityResolver(getEntityResolver());

        documentLoader = loader;
    }

    protected Loader createDocumentLoader() {
        // Built-in support of XInclude 1.1.
        return new XLoader(console);
    }

    /**
     * Returns the document loader used to load XML documents.
     *
     * @see #setDocumentLoader
     */
    public Loader getDocumentLoader() {
        return documentLoader;
    }

    /**
     * Specifies which processor to use to process transcluded documents.
     *
     * @param proc which transclusion processor to use. 
     * May be <code>null</code>, in which case an instance
     * of {@link com.xmlmind.transproc.Processor} is used.
     * @see #getTransclusionProcessor
     */
    public void setTransclusionProcessor(com.xmlmind.transproc.Processor proc) {
        if (proc == null) {
            proc = createTransclusionProcessor();
        }

        transclusionProcessor = proc;
    }

    protected com.xmlmind.transproc.Processor createTransclusionProcessor() {
        return new com.xmlmind.transproc.Processor(console);
    }

    /**
     * Returns the processor used to process transcluded documents.
     *
     * @see #setTransclusionProcessor
     */
    public com.xmlmind.transproc.Processor getTransclusionProcessor() {
        return transclusionProcessor;
    }

    /**
     * Specifies the value of the ID of the <code>structure</code> 
     * to be processed. This may be needed in case the <code>assembly</code> 
     * contains several <code>structure</code>s.
     *
     * @param id the value of the <code>xml:id</code> attribute of 
     * the <code>structure</code> to be processed. 
     * May be <code>null</code>, in which case, the first <code>structure</code>
     * in document order is processed.
     * @see #getProcessedStructId
     */
    public void setProcessedStructId(String id) {
       processedStructId = id;
    }

    /**
     * Returns the value of the ID of the <code>structure</code> 
     * to be processed. May return <code>null</code>.
     *
     * @see #setProcessedStructId
     */
    public String getProcessedStructId() {
        return processedStructId;
    }

    /**
     * Similar to {@link #setOutputFormats} except that specified string 
     * is split around character <code>;</code>.
     */
    public void setOutputFormat(String format) {
        setOutputFormats(
            Profile.splitEffectivityValue(format, EFFECTIVITY_VALUE_SEPARATOR));
    }

    /**
     * Specifies the output formats (generally only one) 
     * which are to be considered by this processor.
     * 
     * @param formats the output formats which are to be considered
     * by this processor. May be <code>null</code>, in which case:
     * <ul>
     * <li>If the <code>structure</code> to be processed has a
     * <code>outputformat</code> attribute, then the value of this attribute
     * is the output format considered by this processor.
     * <li>Otherwise, the "<dfn>implicit format</dfn>" is used.
     * The "implicit format" matches <code>output</code>, 
     * <code>filterin</code>, <code>filterout</code> elements without any 
     * <code>outputformat</code> attribute.
     * </ul>
     * 
     * @see #setProcessedStructId
     * @see #getOutputFormats
     */
    public void setOutputFormats(String[] formats) {
        if (formats != null && formats.length == 0) {
            formats = null;
        }
        outputFormats = formats;
    }

    /**
     * Returns the output formats which are to be considered by this processor.
     * May return <code>null</code>.
     * 
     * @see #setOutputFormats
     */
    public String[] getOutputFormats() {
        return outputFormats;
    }

    /**
     * Specifies whether realized document is to be checked for 
     * cross-reference errors, missing image resources, etc. 
     * Default option is <code>false</code>.
     *
     * @see #getCheckRealizedDocument
     */
    public void setCheckRealizedDocument(boolean check) {
        checkRealizedDocument = check;
    }

    /**
     * Returns <code>true</code> if realized document is to be checked for 
     * cross-reference errors, missing image resources, etc.
     *
     * @see #setCheckRealizedDocument
     */
    public boolean getCheckRealizedDocument() {
        return checkRealizedDocument;
    }

    /**
     * Specifies the profile applied to the realized document.
     * <p>This may be needed if you want the diagnostics reported by
     * the realized document checker to be accurate.
     * 
     * @param profile a list of profiling attribute name/value pairs. 
     * May be <code>null</code>.
     * 
     * @see #getProfile
     * @see #setCheckRealizedDocument
     */
    public void setProfile(String[] profile) {
        if (profile != null && profile.length == 0) {
            profile = null;
        }
        profilingAttrs = profile;
    }

    /**
     * Returns the profile applied to the realized document.
     * May return <code>null</code>.
     * 
     * @see #setProfile
     */
    public String[] getProfile() {
        return profilingAttrs;
    }

    /**
     * Allows to configure this processor using the options
     * of command-line utility <code>assembly</code> (that is, 
     * <code>-struct</code>, <code>-format</code>, etc).
     *
     * @param args command-line options
     * @return index in array <code>args</code> of first unknown option.
     * This may be the index of an unknown option or the index of the assembly 
     * input file.
     * @exception IllegalArgumentException if an usage error is found
     * in <code>args</code>
     */
    public int configure(String[] args) 
        throws IllegalArgumentException {
        ArrayList<String> profilingAttributes = null;

        int l = 0;

        final int argCount = args.length;
        for (; l < argCount; ++l) {
            String arg = args[l];

            if ("-struct".equals(arg)) {
                if (l+1 >= argCount) {
                    throw new IllegalArgumentException(Msg.msg("tooFewArgs"));
                }

                setProcessedStructId(args[++l]);
            } else if ("-format".equals(arg)) {
                if (l+1 >= argCount) {
                    throw new IllegalArgumentException(Msg.msg("tooFewArgs"));
                }

                setOutputFormat(args[++l]);
            } else if ("-check".equals(arg)) {
                setCheckRealizedDocument(true);
            } else if ("-profile".equals(arg)) {
                if (l+2 >= argCount) {
                    throw new IllegalArgumentException(Msg.msg("tooFewArgs"));
                }

                if (profilingAttributes == null) {
                    profilingAttributes = new ArrayList<String>();
                }
                profilingAttributes.add(args[l+1]);
                profilingAttributes.add(args[l+2]);
                l += 2;
            } else if ("-v".equals(arg)) {
                console.setVerbosity(Console.MessageType.INFO);
            } else if ("-vv".equals(arg)) {
                console.setVerbosity(Console.MessageType.VERBOSE);
            } else if ("-vvv".equals(arg)) {
                console.setVerbosity(Console.MessageType.DEBUG);
            } else {
                // Unknown option or input file.
                break;
            }
        }

        if (profilingAttributes != null) {
            setProfile(profilingAttributes.toArray(StringUtil.EMPTY_LIST));
        }

        return l;
    }

    /**
     * Processes specified assembly and returns the realized document.
     * <p>Warning, errors and fatal errors (other than 
     * <code>IOException</code>s) are reported using the 
     * {@link #getConsole console}.
     *
     * @param inURL URL of the assembly input document
     * @param realizedDocURL URL of the realized document.
     * May be <code>null</code> if unimportant or unknown.
     * @return realized document; <code>null</code> if a fatal error has
     * been reported
     * @exception IOException if, for any reason, an I/O exception is
     * raised during the processing.
     * @see #process(URL, File)
     */
    public Document process(URL inURL, URL realizedDocURL) 
        throws IOException {
        Element assembly = loadAssembly(inURL);
        if (assembly == null) {
            return null;
        }

        // ---

        console.info(Msg.msg("processingAssembly", URLUtil.toLabel(inURL)));

        Element processedStruct = findProcessedStruct(assembly);
        if (processedStruct == null) {
            return null;
        }

        // ---

        console.debug(Msg.msg("includingModules"));

        if (!doIncludeModules(processedStruct)) {
            return null;
        }
        
        // ---

        // Null means: implicit output format. Matches all output elements not
        // having an outputformat attribute.
        String[] targetFormats = null;
        if (outputFormats == null) {
            String defaultformat = 
                DOMUtil.getNonEmptyAttribute(processedStruct, 
                                             null, "defaultformat");
            if (defaultformat != null) {
                targetFormats = new String[] { defaultformat };
            }
        } else {
            targetFormats = outputFormats;
        }

        // ---

        parseAssembly(assembly);

        // ---

        Document realizedDoc = DOMUtil.newDocument();
        if (realizedDocURL != null) {
            realizedDoc.setDocumentURI(realizedDocURL.toExternalForm());
        }

        FilterStack filterStack = new FilterStack();
        filterStack.push();

        if (!realize(processedStruct, targetFormats, filterStack,
                     realizedDoc, realizedDoc)) {
            realizedDoc = null;
        }

        filterStack.pop();

        // ---

        if (realizedDoc != null) {
            realizeRelationships(realizedDoc);
        }

        // ---

        if (realizedDoc != null) {
            console.verbose(Msg.msg("processingTransclusions"));

            if (transclusionProcessor.process(realizedDoc) == Boolean.FALSE) {
                console.error(Msg.msg("cannotProcessTransclusions"));
                realizedDoc = null;
            }
        }

        // ---

        if (realizedDoc != null && profilingAttrs != null) {
            console.info(Msg.msg("applyingProfileToRealizedDocument"));
            
            Profile profile = new Profile(EFFECTIVITY_VALUE_SEPARATOR);

            for (int i = 0; i < profilingAttrs.length; i += 2) {
                try {
                    profile.putEntry(profilingAttrs[i], profilingAttrs[i+1]);
                } catch (IllegalArgumentException e) {
                    console.error(ThrowableUtil.reason(e));
                    realizedDoc = null;
                }
            }

            profile.process(realizedDoc);
        }

        // ---

        if (realizedDoc != null && checkRealizedDocument) {
            console.info(Msg.msg("checkingRealizedDocument"));

            Checker checker = new Checker(console);
            checker.setResolver(resolver);

            checker.check(realizedDoc);
        }

        return realizedDoc;
    }

    /**
     * Processes specified assembly and saves the realized document 
     * to specified file.
     * <p>Warning, errors and fatal errors (other than 
     * <code>IOException</code>s) are reported using the 
     * {@link #getConsole console}.
     *
     * @param inURL URL of the assembly input document
     * @param outFile save file of the realized document 
     * @return <code>true</code> if realized document has been saved; 
     * <code>false</code> if a fatal error has been reported
     * @exception IOException if, for any reason, an I/O exception is
     * raised during the processing.
     * @see #process(URL, URL)
     */
    public boolean process(URL inURL, File outFile) 
        throws IOException {
        Document doc = process(inURL, FileUtil.fileToURL(outFile));
        if (doc == null) {
            return false;
        }

        saveRealizedDoc(doc, outFile);
        return true;
    }

    protected void saveRealizedDoc(Document doc, File outFile) 
        throws IOException {
        outFile = outFile.getAbsoluteFile();
        console.info(Msg.msg("savingRealizedDoc", outFile));

        File outDir = outFile.getParentFile();
        if (outDir != null && !outDir.exists()) {
            FileUtil.checkedMkdirs(outDir);
        }

        SaveDocument.save(doc, outFile);
    }

    // -----------------------------------------------------------------------
    // Implementation
    // -----------------------------------------------------------------------

    protected Element loadAssembly(URL inURL) 
        throws IOException {
        console.info(Msg.msg("loadingDoc", URLUtil.toLabel(inURL)));
        Document assemblyDoc = loadDocument(inURL);

        Element assembly = assemblyDoc.getDocumentElement();
        if (!DOMUtil.hasName(assembly, DOCBOOK_NS_URI, "assembly")) {
            console.error(assembly,
                          Msg.msg("notAnAssembly", URLUtil.toLabel(inURL)));
            assembly = null;
        }

        return assembly;
    }

    protected Document loadDocument(URL url) 
        throws IOException {
        InputStream in =
            new BufferedInputStream(URLUtil.openStreamNoCache(url));

        Document doc;
        try {
            doc = loadDocument(in, url);
        } finally {
            in.close();
        }

        return doc;
    }

    protected Document loadDocument(InputStream in, URL url) 
        throws IOException {
        return documentLoader.load(in, url);
    }

    protected Element findProcessedStruct(Element assembly) {
        idToStructure = new HashMap<String,Element>();
        
        Element processedStruct = null;

        Node child = assembly.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "structure")) {
                    String id = DOMUtil.getNonEmptyAttribute(childElement,
                                                             XML_NS_URI, "id");
                    if (processedStruct == null &&
                        (processedStructId == null ||
                         processedStructId.equals(id))) {
                        processedStruct = childElement;
                    }

                    idToStructure.put(id, childElement);
                }
            }

            child = child.getNextSibling();
        }

        if (processedStruct == null) {
            console.error(assembly, 
                          (processedStructId == null)? 
                          Msg.msg("noStructure") :
                          Msg.msg("noSuchElement",
                                  "structure", "xml:id", processedStructId));
        }

        return processedStruct;
    }
    
    // -------------------------------------
    // Include modules from other structures
    //
    // Most basic implementation as TDG v5.2
    // is not clear enough to do better.
    // -------------------------------------
    
    protected boolean includeModules(Element module) {
        String ref = DOMUtil.getNonEmptyAttribute(module, null, "resourceref");
        Element includedStruct = null;
        if (ref != null &&
            (includedStruct = idToStructure.get(ref)) != null) {
            if (!insertModules(includedStruct, module)) {
                return false;
            }

            module.getParentNode().removeChild(module);
            // Done.
            return true;
        }
        
        return doIncludeModules(module);
    }
    
    protected boolean doIncludeModules(Element structOrModule) {
        Node child = structOrModule.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;
                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "module") &&
                    !includeModules(childElement)) {
                    return false;
                }
            }

            child = next;
        }

        return true;
    }
    
    protected boolean insertModules(Element srcStruct, Element beforeModule) {
        NamedNodeMap attrs = null;
        if (beforeModule.getFirstChild() != null ||
            ((attrs = beforeModule.getAttributes()) != null &&
             // That is, other than @resourceref.
             attrs.getLength() > 1)) {
            String srcStructId =
                DOMUtil.getNonEmptyAttribute(srcStruct, XML_NS_URI, "id");
            console.warning(beforeModule, Msg.msg("ignoringModuleContent",
                                                  srcStructId));
        }
        
        // ---
        
        Node dst = beforeModule.getParentNode();

        Element insertedModule = null;
        String ref =
            DOMUtil.getNonEmptyAttribute(srcStruct, null, "resourceref");
        if (ref != null) {
            // Here we consider that:
            //
            // structure [renderas=chapter] resourceref="ch1"
            //     [merge]
            //     module resourceref="s1"
            //     module resourceref="s2"
            //
            // is equivalent to:
            //
            // structure
            //     module [renderas=chapter] resourceref="ch1"
            //         [merge]
            //         module resourceref="s1"
            //         module resourceref="s2"

            insertedModule =
              dst.getOwnerDocument().createElementNS(DOCBOOK_NS_URI, "module");
            insertedModule.setAttributeNS(null, "resourceref", ref);
            
            String renderas =
                DOMUtil.getNonEmptyAttribute(srcStruct, null, "renderas");
            if (renderas != null) {
                insertedModule.setAttributeNS(null, "renderas", renderas);
            }
            
            Element merge =
              DOMUtil.getChildElementByName(srcStruct, DOCBOOK_NS_URI, "merge");
            if (merge != null) {
                insertedModule.appendChild(merge.cloneNode(/*deep*/ true));
            }
            
            dst.insertBefore(insertedModule, beforeModule);
        }

        // ---
        
        Node child = srcStruct.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;
                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "module")) {
                    Element copy =
                        (Element) childElement.cloneNode(/*deep*/ true);
                    if (insertedModule != null) {
                        insertedModule.appendChild(copy);
                    } else {
                        dst.insertBefore(copy, beforeModule);
                    }
                    
                    if (!includeModules(copy)) {
                        return false;
                    }
                }
            }

            child = child.getNextSibling();
        }

        return true;
    }
    
    // ---------------------------------
    // Parse assembly
    // ---------------------------------

    protected void parseAssembly(Element assembly) {
        idToResource = new HashMap<String,Resource>();
        nameToTransform = new HashMap<String,Transform>();
        grammarToTransform = new HashMap<String,Transform>();

        relationshipList = new ArrayList<Relationship>();
        idToRealized = new HashMap<String,Element>();

        // ---

        Node child = assembly.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOCBOOK_NS_URI.equals(childElement.getNamespaceURI())) {
                    String localName = childElement.getLocalName();

                    if ("resources".equals(localName)) {
                        parseResources(childElement);
                    } else if ("transforms".equals(localName)) {
                        parseTransforms(childElement);
                    } else if ("relationships".equals(localName)) {
                        parseRelationships(childElement);
                    }
                }
            }

            child = child.getNextSibling();
        }
    }

    // ---------------------------------
    // Parse resources
    // ---------------------------------

    protected void parseResources(Element resources) {
        String commonGrammar = 
            DOMUtil.getNonEmptyAttribute(resources, null, "grammar");

        for (Node child = resources.getFirstChild(); 
             child != null;
             child = child.getNextSibling()) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "resource")) {
                    String id = 
                        DOMUtil.getNonEmptyAttribute(childElement, 
                                                     XML_NS_URI, "id");
                    if (id == null) {
                        console.warning(childElement, 
                                        Msg.msg("missingAttribute", "xml:id"));
                        continue;
                    }

                    URL url = getURLAttribute(childElement, null, "href");
                    if (url == null) {
                        console.warning(childElement, 
                                        Msg.msg("missingOrInvalidAttribute", 
                                                "href"));
                        continue;
                    }

                    String grammar = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "grammar");
                    if (grammar == null) {
                        grammar = commonGrammar;
                    }

                    // Only one of @grammar and @transform must be specified.
                    // However @grammar, either specified or common,
                    // has priority over @transform.
                    String transform = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "transform");
                    
                    Resource resource =
                        createResource(id, url, grammar, transform);
                    console.debug(Msg.msg("parsedElement", resource));

                    idToResource.put(id, resource);
                }
            }
        }
    }

    protected URL getURLAttribute(Element element,
                                  String namespace,
                                  String localName) {
        String value = 
            DOMUtil.getNonEmptyAttribute(element, namespace, localName);
        if (value == null) {
            return null;
        }

        URL url = null;
        try {
            url = resolveURI(value, DOMUtil.getBaseURL(element));
        } catch (MalformedURLException ignored) {}

        return url;
    }

    protected Resource createResource(String id, URL url,
                                      String grammar, String transform) {
        return new Resource(id, url, grammar, transform);
    }

    // ---------------------------------
    // Parse transforms
    // ---------------------------------

    protected void parseTransforms(Element transforms) {
        for (Node child = transforms.getFirstChild();
             child != null;
             child = child.getNextSibling()) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "transform")) {
                    URL url = getURLAttribute(childElement, null, "href");
                    if (url == null) {
                        console.warning(childElement, 
                                        Msg.msg("missingOrInvalidAttribute", 
                                                "href"));
                        continue;
                    }

                    String name = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "name");

                    String grammar = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "grammar");

                    if (name == null && grammar == null) {
                        console.warning(childElement, 
                                        Msg.msg("missingAttribute", "name"));
                        continue;
                    } else if (name != null && grammar != null) {
                        console.warning(childElement, 
                                        Msg.msg("exclusiveAttributes", 
                                                "name", "grammar"));
                        // Proceed.
                    }

                    Transform transform = createTransform(name, grammar, url);
                    console.debug(Msg.msg("parsedElement", transform));

                    if (name != null) {
                        nameToTransform.put(name, transform);
                    } 
                    if (grammar != null) {
                        grammarToTransform.put(grammar, transform);
                    }
                }
            }
        }
    }

    protected Transform createTransform(String name, String grammar, URL url) {
        return new Transform(name, grammar, url);
    }

    // ---------------------------------
    // Parse relationships
    // ---------------------------------

    protected void parseRelationships(Element relationships) {
        Relationship common = new Relationship(relationships);
        String value = 
            DOMUtil.getNonEmptyAttribute(relationships, null, "type");
        if (value != null) {
            // type contains NMTOKENS (???).
            String[] split = XMLText.splitList(value);
            if (split != null && split.length > 0) {
                common.type = split[0];
            }
        }

        final int firstAdded = relationshipList.size();

        for (Node child = relationships.getFirstChild();
             child != null;
             child = child.getNextSibling()) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "relationship")) {
                    Relationship rel = parseRelationship(childElement);
                    if (rel != null) {
                        relationshipList.add(rel);
                    }
                } else if (DOMUtil.hasName(childElement,
                                           DOCBOOK_NS_URI, "instance")) {
                    Instance instance = parseInstance(childElement);
                    if (instance != null) {
                        common.add(instance);
                    }
                }
            }
        }

        // ---

        final int count = relationshipList.size();
        if (count == firstAdded) {
            // No added relationships ---

            if (common.instances.length > 0) {
                if (common.instances.length <= 1) {
                    console.warning(common.source, 
                                    Msg.msg("uselessRelationship"));
                } else {
                    relationshipList.add(common);
                }
            }
        } else {
            for (int i = count-1; i >= firstAdded; --i) {
                Relationship rel = relationshipList.get(i);
                if (rel.type == null) {
                    rel.type = common.type;
                }

                for (Instance instance : common.instances) {
                    if (!rel.contains(instance)) {
                        rel.add(instance);
                    }
                    // Otherwise common instance overriden in rel.
                }

                if (rel.instances.length <= 1) {
                    console.warning(rel.source, 
                                    Msg.msg("uselessRelationship"));
                    relationshipList.remove(i);
                }
            }
        }
    }

    protected Relationship parseRelationship(Element relationship) {
        Relationship rel = new Relationship(relationship);
        rel.type = DOMUtil.getNonEmptyAttribute(relationship, null, "type");

        for (Node child = relationship.getFirstChild();
             child != null;
             child = child.getNextSibling()) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement, DOCBOOK_NS_URI, "instance")) {
                    Instance instance = parseInstance(childElement);
                    if (instance != null) {
                        rel.add(instance);
                    }
                } else if (DOMUtil.hasName(childElement,
                                           DOCBOOK_NS_URI, "association")) {
                    String assoc = childElement.getTextContent();
                    if (assoc != null && 
                        (assoc =
                         XMLText.collapseWhiteSpace(assoc)).length() > 0) {
                        rel.association = assoc;
                    }
                }
            }
        }

        return rel;
    }

    protected Instance parseInstance(Element instance) {        
        String id = DOMUtil.getNonEmptyAttribute(instance, null, "linkend");
        if (id == null) {
            console.error(instance, Msg.msg("missingAttribute", "linkend"));
            return null;
        }

        Linking linking = null;
        String value = DOMUtil.getNonEmptyAttribute(instance, null, "linking");
        if (value != null) {
            linking = Linking.fromString(value);
            if (linking == null) {
                console.warning(instance,
                                Msg.msg("invalidAttribute", "linking"));
            }
        }

        return new Instance(linking, id);
    }

    // ---------------------------------
    // realize
    // ---------------------------------

    protected boolean realize(Element moduleOrStruct, 
                              String[] targetFormats, FilterStack filterStack, 
                              Node realizedParent, Document realizedDoc) 
        throws IOException {
        String resourceId = DOMUtil.getNonEmptyAttribute(moduleOrStruct,
                                                         null, "resourceref");
        if (resourceId == null) {
            // Ignore "contentonly" and "omittitles" on modules not having
            // "resourceref".
            moduleOrStruct.removeAttributeNS(null, "contentonly");
            moduleOrStruct.removeAttributeNS(null, "omittitles");
        }

        // ---

        Element realized = 
            realize(moduleOrStruct, targetFormats, filterStack, realizedDoc);
        if (realized == null) {
            return false;
        }

        if (realized == moduleOrStruct) {
            // The module or structure has been filtered out or suppressed.
            if ("structure".equals(moduleOrStruct.getLocalName())) {
                console.error(moduleOrStruct, Msg.msg("excludedStructure"));
                return false;
            } else {
                return true;
            }
        }
        
        // ---

        String moduleId = DOMUtil.getNonEmptyAttribute(moduleOrStruct,
                                                       XML_NS_URI, "id");

        boolean contentOnly =
            DOMUtil.getBooleanAttribute(moduleOrStruct, null, "contentonly", 
                                        false);
        if (contentOnly) {
            // The xml:base and xml:lang attributes need to be copied to the
            // content of the realized module.

            URL baseURL = DOMUtil.getBaseURL(realized);
            String xmlLang = DOMUtil.lookupXMLLang(realized);

            Node realizedNode = 
                DOMUtil.getChildElementByName(realized, DOCBOOK_NS_URI, "info");
            if (realizedNode != null &&
                realizedNode.getUserData("omittitles") != null) {
                // Specificity: contentonly=true and omittitles=true ==>
                // skip info altogether.
                realizedNode = realizedNode.getNextSibling();
            } else {
                realizedNode = realized.getFirstChild();
            }

            while (realizedNode != null) {
                Node next = realizedNode.getNextSibling();

                realized.removeChild(realizedNode);

                if (realizedNode.getNodeType() == Node.ELEMENT_NODE) {
                    baseAndLangFixup((Element) realizedNode, baseURL, xmlLang);
                }

                realizedParent.appendChild(realizedNode);

                realizedNode = next;
            }

            if (DOMUtil.getChildElementByName(moduleOrStruct, DOCBOOK_NS_URI, 
                                              "module") != null) {
                console.warning(moduleOrStruct, 
                                Msg.msg("contentOnlyAndSubModules"));
            }

            // realizedParent not changed in this case.
            // (Note that attribute contentonly is not allowed on a structure.)
        } else {
            realizedParent.appendChild(realized);

            // New realizedParent.
            realizedParent = realized;

            // Needed to implement relationships.
            // One resource, two possible IDs: 
            // the resource xml:id and the module xml:id.
            if (resourceId != null) {
                idToRealized.put(resourceId, realized);
            }
            if (moduleId != null) {
                idToRealized.put(moduleId, realized);
            }
        }

        // ---

        boolean insertRelationshipsPI = 
            ((resourceId != null || moduleId != null) && !contentOnly);
        Node realizedBody = null;

        Node child = moduleOrStruct.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement, DOCBOOK_NS_URI, "module")) {
                    if (insertRelationshipsPI &&
                        realizedBody == null &&
                        !DOMUtil.getBooleanAttribute(childElement, 
                                                     null, "contentonly", 
                                                     false)) {
                        realizedBody = realizedParent.getLastChild();
                    }
                    
                    filterStack.push();

                    boolean done =
                        realize(childElement, targetFormats, filterStack,
                                realizedParent, realizedDoc);

                    filterStack.pop();

                    if (!done) {
                        return false;
                    }
                }
            }

            child = child.getNextSibling();
        }

        // ---

        if (insertRelationshipsPI) {
            StringBuilder buffer = new StringBuilder();
            if (resourceId != null) {
                buffer.append(resourceId);
            }
            if (moduleId != null) {
                if (buffer.length() > 0) {
                    buffer.append(' ');
                }
                buffer.append(moduleId);
            }

            ProcessingInstruction pi = 
                realizedDoc.createProcessingInstruction("realize-relationships",
                                                        buffer.toString());
            if (realizedBody == null) {
                realizedParent.appendChild(pi);
            } else {
                realizedParent.insertBefore(pi, realizedBody.getNextSibling());
            }
        }

        return true;
    }

    protected static final String[] OPTIONAL_TITLE_ELEMENT_NAMES = {
        "acknowledgements",
        "bibliography",
        "colophon",
        "dedication",
        "glossary",
        "index",
        "toc",
    };

    protected Element realize(Element moduleOrStruct, 
                              String[] targetFormats, FilterStack filterStack, 
                              Document realizedDoc) 
        throws IOException {
        // Also parses module/@chunk and module/@renderas.
        Output output = parseOutputs(moduleOrStruct, targetFormats);
        if (output == null) {
            // Error parsing outputs.
            return null;
        }

        if (output.suppress) {
            // Means: filtered out or supressed.
            return moduleOrStruct;
        }

        parseFilters(moduleOrStruct, targetFormats, filterStack);
        if (filterStack.isUnconditionalFilterOut()) {
            // Means: filtered out or supressed.
            return moduleOrStruct;
        }

        Element merge = 
            DOMUtil.getChildElementByName(moduleOrStruct,
                                          DOCBOOK_NS_URI, "merge");
        
        String[] resourceId = new String[1];
        Resource resource = getResource(moduleOrStruct, resourceId);
        if (resource == null) {
            if (resourceId[0] != null) {
                // Resource not found.
                return null;
            }
            // Otherwise, no @resourceref.

            if (output.renderAs == null) {
                console.error(moduleOrStruct,
                              Msg.msg("missingAttribute", "renderas"));
                return null;
            }

            if ((merge == null ||
                 DOMUtil.getChildElementByName(merge, DOCBOOK_NS_URI,
                                               "title") == null) &&
                !StringList.contains(OPTIONAL_TITLE_ELEMENT_NAMES,
                                     output.renderAs.getLocalPart())) {
                // Not an error.
                // In some rare cases, it's OK to have a module without
                // an @resourceref and no merge/title descendant.
                console.warning(moduleOrStruct,
                                Msg.msg("missingChildElement", "merge"));
            }
        }

        Element realized;
        if (resource == null) {
            realized = createElement(moduleOrStruct, output.renderAs,
                                     realizedDoc);
        } else {
            realized = includeElement(moduleOrStruct, resource, output,
                                      realizedDoc);
        }
        if (realized == null) {
            return null;
        }

        if (merge != null) {
            // Specificity: common attributes found in merge are
            // added to or replace those found in realized resource.
            mergeCommonAttributes(merge, realized);
            
            if (!mergeInfo(merge, realized, realizedDoc)) {
                return null;
            }
        }

        // Ignore structure/revhistory. It does not contribute to the realized
        // document.
        
        setChunking(output, realized, realizedDoc, moduleOrStruct);

        if (applyFilters(realized, filterStack)) {
            // realized has been excluded as a whole.
            return moduleOrStruct;
        }

        return realized;
    }

    protected Resource getResource(Element element, String[] id) {
        id[0] = DOMUtil.getNonEmptyAttribute(element, null, "resourceref");
        if (id[0] == null) {
            return null;
        } else {
            Resource resource = idToResource.get(id[0]);
            if (resource == null) {
                console.error(element, Msg.msg("noSuchElement", "resource", 
                                               "xml:id", id[0]));
            }
            return resource;
        }
    }

    protected static final void baseAndLangFixup(Element element,
                                                 URL baseURL, String xmlLang) {
        if (baseURL != null) {
            String newBase = null;

            String oldBase = DOMUtil.getNonEmptyAttribute(element, 
                                                          XML_NS_URI, "base");
            if (oldBase == null) {
                newBase = baseURL.toExternalForm();
            } else {
                try {
                    URL newBaseURL = 
                        URLUtil.createURL(baseURL, oldBase);
                    newBase = newBaseURL.toExternalForm();
                } catch (MalformedURLException ignored) {}
            }

            if (newBase != null) {
                element.setAttributeNS(XML_NS_URI, "xml:base", newBase);
            }
        }

        if (xmlLang != null &&
            DOMUtil.getNonEmptyAttribute(element, XML_NS_URI, "lang") == null) {
            element.setAttributeNS(XML_NS_URI, "xml:lang", xmlLang);
        }
    }

    // ---------------------------------
    // parseOutputs
    // ---------------------------------

    protected Output parseOutputs(Element moduleOrStruct, 
                                  String[] targetFormats) {
        Output output = new Output();

        parseChunk(moduleOrStruct, output);

        output.renderAs = DOMUtil.getQNameAttribute(moduleOrStruct, 
                                                    null, "renderas", null);
        boolean moduleHasRenderAs = (output.renderAs != null);
        boolean outputHasRenderAs = false;

        Node child = moduleOrStruct.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                if (DOMUtil.hasName(childElement,
                                    DOCBOOK_NS_URI, "output") &&
                    matchesFormats(childElement, targetFormats)) {

                    parseChunk(childElement, output);

                    String file = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "file");
                    if (file != null) {
                        output.file = file;
                    }

                    QName renderAs = 
                        DOMUtil.getQNameAttribute(childElement,
                                                  null, "renderas", null);
                    if (renderAs != null) {
                        output.renderAs = renderAs;
                        outputHasRenderAs = true;
                    }

                    String suppress = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "suppress");
                    if (suppress != null) {
                        if ("true".equals(suppress)) {
                            output.suppress = true;
                        } else if ("false".equals(suppress)) {
                            output.suppress = false;
                        }
                    }

                    // transform (or grammar for compatibility with V5.1) ---

                    Transform transform = null;

                    String value = 
                        DOMUtil.getNonEmptyAttribute(childElement,
                                                     null, "transform");
                    if (value != null) {
                        transform = nameToTransform.get(value);
                        if (transform == null) {
                            console.error(childElement, 
                                          Msg.msg("noSuchElement",
                                                  "transform", "name", value));
                            return null;
                        }
                    } else {
                        value = DOMUtil.getNonEmptyAttribute(childElement,
                                                             null, "grammar");
                        if (value != null) {
                            console.warning(childElement, 
                                            Msg.msg("deprecatedAttribute",
                                                    "grammar"));
                            
                            transform = grammarToTransform.get(value);
                            if (transform == null) {
                                console.error(childElement, 
                                              Msg.msg("noSuchElement",
                                                      "transform", "grammar", 
                                                      value));
                                return null;
                            }
                        }
                    }

                    if (transform != null) {
                        if (output.transforms == null) {
                            output.transforms = new Transform[] { transform };
                        } else {
                            output.transforms = 
                                ArrayUtil.append(output.transforms, transform);
                        }
                    }
                }
            }

            child = child.getNextSibling();
        }

        if (moduleHasRenderAs && outputHasRenderAs) {
            console.warning(moduleOrStruct, 
                            Msg.msg("exclusiveAttributes", 
                                   moduleOrStruct.getLocalName() + "/@renderAs",
                                   "output/@renderAs"));
        }

        return output;
    }

    protected static final void parseChunk(Element element, Output output) {
        String chunk = DOMUtil.getNonEmptyAttribute(element, null, "chunk");
        if (chunk != null) {
            if ("true".equals(chunk)) {
                output.chunk = Boolean.TRUE;
            } else if ("false".equals(chunk)) {
                output.chunk = Boolean.FALSE;
            } else if ("auto".equals(chunk)) {
                output.chunk = null;
            }
        }
    }

    protected static final boolean matchesFormats(Element element,
                                                  String[] targetFormats) {
        String format = DOMUtil.getNonEmptyAttribute(element,
                                                     null, "outputformat");
        if (format == null) {
            return true;
        } else {
            if (targetFormats == null) {
                // Implicit target format (null) cannot match a real format
                // (e.g. "EPUB").
                return false;
            } else {
                for (String targetFormat : targetFormats) {
                    if (format.indexOf(';') >= 0) {
                        String[] tokens = StringUtil.split(format, ';');
                        for (String token : tokens) {
                            token = token.trim();
                            if (token.length() > 0 && 
                                token.equals(targetFormat)) {
                                return true;
                            }
                        }
                    } else {
                        if (format.equals(targetFormat)) {
                            return true;
                        }
                    }
                }

                return false;
            }
        }
    }

    // ---------------------------------
    // parseFilters
    // ---------------------------------

    protected static final char EFFECTIVITY_VALUE_SEPARATOR = ';';

    protected static final String[] EFFECTIVITY_ATTRIBUTE_LOCAL_NAMES = {
        "arch",
        "audience",
        "condition",
        "conformance",
        "os",
        // outputformat
        "revision",
        "security",
        "userlevel",
        "vendor",
        "wordsize"
    };

    protected void parseFilters(Element moduleOrStruct, 
                                String[] targetFormats,
                                FilterStack filterStack) {
        Filter[] filters = filterStack.top();

        Node child = moduleOrStruct.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                boolean filterIn = DOMUtil.hasName(childElement,
                                                   DOCBOOK_NS_URI, "filterin");
                boolean filterOut = DOMUtil.hasName(childElement,
                                                   DOCBOOK_NS_URI, "filterout");

                if ((filterIn || filterOut) &&
                    matchesFormats(childElement, targetFormats)) {
                    boolean addedFilters = false;

                    for (String attrName : EFFECTIVITY_ATTRIBUTE_LOCAL_NAMES) {
                        String value = 
                            DOMUtil.getNonEmptyAttribute(childElement, 
                                                         null, attrName);
                        if (value != null) {
                            String[] attrValues = Profile.splitEffectivityValue(
                                value, EFFECTIVITY_VALUE_SEPARATOR);
                            if (attrValues != null) {
                                Filter filter = 
                                    new Filter(attrName, attrValues, filterOut);
                                filters = ArrayUtil.append(filters, filter);
                                addedFilters = true;
                            }
                        }
                    }

                    if (filterOut && !addedFilters) {
                        filters = ArrayUtil.append(filters, new Filter());
                    }
                }
            }

            child = child.getNextSibling();
        }

        filterStack.top(filters);
    }

    // ---------------------------------
    // mergeCommonAttributes
    // ---------------------------------

    protected static final String TRANS_NS_URI =
        "http://docbook.org/ns/transclusion";
    
    protected static final String[] COMMON_ATTRIBUTE_NAMES = {
        null, "annotations",
        null, "dir",
        null, "remap",
        null, "revisionflag",
        null, "role",
        TRANS_NS_URI, "idfixup", /*V5.2*/
        TRANS_NS_URI, "linkscope",
        TRANS_NS_URI, "suffix",
        null, "version",
        null, "base",
        XML_NS_URI, "id",
        XML_NS_URI, "lang",
        null, "xreflabel"
    };

    protected static final boolean isCommonAttribute(String attrNS,
                                                     String attrLocalName) {
        final int count = COMMON_ATTRIBUTE_NAMES.length;
        for (int i = 0; i < count; i += 2) {
            if (COMMON_ATTRIBUTE_NAMES[i+1].equals(attrLocalName) &&
                ObjectUtil.equals(COMMON_ATTRIBUTE_NAMES[i], attrNS)) {
                return true;
            }
        }
        
        return false;
    }
    
    protected void mergeCommonAttributes(Element merge, Element realized) {
        final NamedNodeMap attrs = merge.getAttributes();
        final int attrCount = attrs.getLength();

        for (int i = 0; i < attrCount; ++i) {
            Attr attr = (Attr) attrs.item(i);

            String attrNS = attr.getNamespaceURI();
            String attrLocalName = attr.getLocalName(); 

            if (isCommonAttribute(attrNS, attrLocalName)) {
                realized.setAttributeNS(attrNS, attr.getName(), // QName.
                                        attr.getValue());
            }
        }
    }
    
    // ---------------------------------
    // mergeInfo
    // ---------------------------------

    protected boolean mergeInfo(Element infoOrMerge, Element realized, 
                                Document realizedDoc) 
        throws IOException {
        Element dst = 
            DOMUtil.getChildElementByName(realized, DOCBOOK_NS_URI, "info");
        if (dst == null) {
            dst = realizedDoc.createElementNS(DOCBOOK_NS_URI, "info");
            realized.insertBefore(dst, realized.getFirstChild());
        }

        // ---

        if (DOMUtil.hasName(infoOrMerge, DOCBOOK_NS_URI, "merge")) {
            Element loadedSrc = null;
            URL baseURL = null;
            String xmlLang = null;

            String[] resourceId = new String[1];
            Resource resource = getResource(infoOrMerge, resourceId);
            if (resource == null) {
                if (resourceId[0] != null) {
                    // Resource not found.
                    return false;
                }
                // Otherwise, no @resourceref.
            } else {
                Element loaded =
                    loadResource(resource, new Output(), infoOrMerge);
                if (loaded == null) {
                    return false;
                }

                baseURL = DOMUtil.getBaseURL(loaded);
                xmlLang = DOMUtil.lookupXMLLang(loaded);

                Element copy = 
                    (Element) realizedDoc.importNode(loaded, /*deep*/ true);

                // Move titles into info if needed to.
                loadedSrc = normalizeInfo(copy, /*discardTitles*/ false,
                                          realizedDoc);
            }

            if (loadedSrc != null) {
                mergeInfo(loadedSrc, baseURL, xmlLang, dst);
            }
        }

        // ---

        URL baseURL = DOMUtil.getBaseURL(infoOrMerge);
        String xmlLang = DOMUtil.lookupXMLLang(infoOrMerge);

        Element src =
            (Element) realizedDoc.importNode(infoOrMerge, /*deep*/ true);
        mergeInfo(src, baseURL, xmlLang, dst);

        return true;
    }

    protected static final String[] TITLE_NAMES = {
        "subtitle",
        "titleabbrev",
        "title"
    };
    
    protected static final boolean isTitleElement(Element e) {
        for (String titleName : TITLE_NAMES) {
            if (DOMUtil.hasName(e, DOCBOOK_NS_URI, titleName)) {
                return true;
            }
        }
        return false;
    }
    
    protected static final void mergeInfo(Element src, 
                                          URL baseURL, String xmlLang, 
                                          Element dst) {
        Node node = src.getFirstChild();
        while (node != null) {
            Node next = node.getNextSibling();

            if (node.getNodeType() == Node.ELEMENT_NODE) {
                Element moved = (Element) node;
            
                src.removeChild(moved);

                baseAndLangFixup(moved, baseURL, xmlLang);
                
                Element replaced = 
                    DOMUtil.getChildElementByName(dst, moved.getNamespaceURI(),
                                                  moved.getLocalName());
                if (replaced != null) {
                    dst.replaceChild(moved, replaced);
                } else {
                    if (isTitleElement(moved)) {
                        dst.insertBefore(moved, dst.getFirstChild());
                    } else {
                        dst.appendChild(moved);
                    }
                }
            }
            
            node = next;
        }
    }

    // ---------------------------------
    // setChunking
    // ---------------------------------

    protected void setChunking(Output output, Element realized, 
                               Document realizedDoc, Element errorLocation) {
        if (output.chunk == Boolean.FALSE) {
            addPI("dbhtml", "stop-chunking", realized, realizedDoc);
        } else if (output.chunk == Boolean.TRUE) {
            console.warning(errorLocation, 
                            Msg.msg("featureNotImplemented", "chunk=true"));
        }

        if (output.file != null) {
            String parentPath, baseName;
            int pos = output.file.lastIndexOf('/');
            if (pos < 0) {
                parentPath = null;
                baseName = output.file;
            } else {
                parentPath = output.file.substring(0, pos); 
                if (parentPath.length() == 0) {
                    parentPath = "/";
                }

                baseName = output.file.substring(pos+1);
                if (baseName.length() == 0) {
                    baseName = null;
                }
            }

            StringBuilder buffer = new StringBuilder();
            if (parentPath != null) {
                buffer.append("dir=\"");
                buffer.append(parentPath);
                buffer.append('"');
            }

            if (baseName != null) {
                if (parentPath != null) {
                    buffer.append(' ');
                }

                buffer.append("filename=\"");
                buffer.append(baseName);
                buffer.append('"');
            }

            if (buffer.length() > 0) {
                addPI("dbhtml", buffer.toString(), realized, realizedDoc);
            }
        }
    }

    protected static final void addPI(String target, String data,
                                      Element realized, Document realizedDoc) {
        ProcessingInstruction pi = 
            realizedDoc.createProcessingInstruction(target, data);
        realized.insertBefore(pi, realized.getFirstChild());
    }

    // ---------------------------------
    // createElement
    // ---------------------------------

    protected Element createElement(Element moduleOrStruct, QName renderAs,
                                    Document realizedDoc) {
        Element realized = 
           realizedDoc.createElementNS(DOCBOOK_NS_URI, renderAs.getLocalPart());

        if ("structure".equals(moduleOrStruct.getLocalName())) {
            String version = DOMUtil.getNonEmptyAttribute(
                moduleOrStruct.getOwnerDocument().getDocumentElement(),
                null, "version");
            if (version == null) {
                version = DEFAULT_DOCBOOK_VERSION;
            }
            realized.setAttributeNS(null, "version", version);
        }

        // Common attributes found on moduleOrStruct do not contribute
        // to realized document.
        
        // Info found in moduleOrStruct does not contribute to realized
        // document.
        
        return realized;
    }

    // ---------------------------------
    // includeElement
    // ---------------------------------

    protected Element includeElement(Element moduleOrStruct, Resource resource,
                                     Output output, Document realizedDoc) 
        throws IOException {
        Element loaded = loadResource(resource, output,
                                      /*errorLocation*/ moduleOrStruct);
        if (loaded == null) {
            return null;
        }

        Element realized = 
            (Element) realizedDoc.importNode(loaded, /*deep*/ true);
        
        // renderas ---

        if (output.renderAs != null && 
            !DOMUtil.hasName(realized, output.renderAs)) {
            realized = (Element) 
                realizedDoc.renameNode(realized,
                                       DOMUtil.getNamespaceURI(output.renderAs),
                                       DOMUtil.getQName(output.renderAs));
        }

        // xml:base ---
        // Keep fragment and query as is if any.

        realized.setAttributeNS(XML_NS_URI, "xml:base",
                                resource.url.toExternalForm());

        // Common attributes found on moduleOrStruct do not contribute
        // to realized document.
        
        // Move titles into info if needed to ---

        boolean omittitles = 
            DOMUtil.getBooleanAttribute(moduleOrStruct, null, "omittitles", 
                                        false);
        normalizeInfo(realized, omittitles, realizedDoc);

        // Info found in moduleOrStruct does not contribute to realized
        // document.
        
        return realized;
    }

    // ---------------------------------
    // loadResource
    // ---------------------------------

    protected Element loadResource(Resource resource, Output output,
                                   Element errorLocation) {
        // Simply load resource? ---

        if (resource.grammar == null && resource.transform == null &&
            output.transforms == null) {
            return loadResource(resource, errorLocation);
        }

        // No, transform resource ---

        if (resource.url.getRef() != null) {
            console.error(errorLocation,
                          Msg.msg("fragmentAndTransformation", 
                                  URLUtil.toLabel(resource.url)));
            return null;
        }

        Transform firstTransform = null;
        if (resource.grammar != null) {
            firstTransform = grammarToTransform.get(resource.grammar);
            if (firstTransform == null) {
                console.error(errorLocation, 
                              Msg.msg("noSuchElement", "transform", "grammar", 
                                      resource.grammar));
                return null;
            }
        } else {
            if (resource.transform != null) {
                firstTransform = nameToTransform.get(resource.transform);
                if (firstTransform == null) {
                    console.error(errorLocation, 
                                  Msg.msg("noSuchElement", "transform", "name",
                                          resource.transform));
                    return null;
                }
            }
        }
        
        Transform[] transforms = output.transforms;
        if (transforms == null) {
            assert(firstTransform != null);
            transforms = new Transform[] { firstTransform };
        } else {
            if (firstTransform != null) {
                transforms = ArrayUtil.prepend(transforms, firstTransform);
            }
        }

        InputStream in = null;

        for (Transform transform : transforms) {
            byte[] transformed = null;

            try {
                console.verbose(Msg.msg("transformingResource",
                                        URLUtil.toLabel(resource.url), 
                                        URLUtil.toLabel(transform.url)));

                if (in == null) {
                    in = URLUtil.openStreamNoCache(resource.url);
                }
                transformed = transformResource(transform, in, resource.url,
                                                errorLocation);

                console.debug(Msg.msg("transformationResult", 
                                      new String(transformed, "UTF-8")));
            } catch (Exception e) {
                console.error(errorLocation, 
                              Msg.msg("cannotTransformResource", 
                                      URLUtil.toLabel(resource.url), 
                                      URLUtil.toLabel(transform.url),
                                      ThrowableUtil.reason(e)));
            } finally {
                if (in != null) {
                    try { in.close(); } catch (IOException ignored) {}
                }
            }

            if (transformed == null) {
                return null;
            }

            in = new ByteArrayInputStream(transformed);
        }

        Element loaded = null;
        try {
            console.verbose(Msg.msg("loadingTransformedResource",
                                    URLUtil.toLabel(resource.url)));

            if (in == null) {
                in = URLUtil.openStreamNoCache(resource.url);
            }
            Document doc = loadDocument(in, resource.url);

            loaded = doc.getDocumentElement();
        } catch (IOException e) {
            console.error(errorLocation, 
                          Msg.msg("cannotLoadTransformedResource", 
                                  URLUtil.toLabel(resource.url),
                                  ThrowableUtil.reason(e)));
        } finally {
            if (in != null) {
                try { in.close(); } catch (IOException ignored) {}
            }
        }

        return loaded;
    }

    protected Element loadResource(Resource resource, Element errorLocation) {
        URL url = resource.url;
        String fragment = URLUtil.getFragment(url);
        if (fragment != null) {
            url = URLUtil.setRawFragment(url, null);
        }

        Document doc = null;
        try {
            console.verbose(Msg.msg("loadingResource",
                                    URLUtil.toLabel(resource.url)));

            doc = loadDocument(url);
        } catch (IOException e) {
            console.error(errorLocation, 
                          Msg.msg("cannotLoadResource", 
                                  URLUtil.toLabel(resource.url),
                                  ThrowableUtil.reason(e)));
            return null;
        }

        Element root = doc.getDocumentElement();
        if (!DOCBOOK_NS_URI.equals(root.getNamespaceURI())) {
            console.error(errorLocation,
                          Msg.msg("notADocBook", URLUtil.toLabel(url)));
            return null;
        }

        Element loaded;
        if (fragment != null) {
            loaded = DOMUtil.findElementById(root, fragment);
            if (loaded == null) {
                console.error(errorLocation,
                              Msg.msg("noSuchFragment", URLUtil.toLabel(url),
                                      fragment));
            }
        } else {
            loaded = root;
        }

        return loaded;
    }

    // ---------------------------------
    // transformResource
    // ---------------------------------

    protected byte[] transformResource(Transform transform,
                                       InputStream in, URL url,
                                       Element errorLocation) 
        throws Exception {
        if (transform.impl == null) {
            transform.impl = getTransformerTemplates(transform, errorLocation);
            if (transform.impl == null) {
                return null;
            }
        }

        Transformer transformer;
        try {
            transformer = ((Templates) transform.impl).newTransformer();
        } catch (Exception e) {
            console.error(errorLocation, 
                          Msg.msg("cannotCreateTransformation", 
                                  URLUtil.toLabel(transform.url), 
                                  ThrowableUtil.reason(e)));
            return null;
        }

        // For use by document().
        transformer.setURIResolver(getURIResolver());
        transformer.setErrorListener(new ConsoleErrorListener(console));

        XMLReader parser;
        try {
            parser = LoaderImpl.createXMLReader(/*xincludeAware*/ true);
            parser.setEntityResolver(getEntityResolver());
        } catch (Exception shouldNotHappen) {
            throw new RuntimeException(ThrowableUtil.reason(shouldNotHappen));
        }

        InputSource input = new InputSource(in);
        String systemId = url.toExternalForm();
        input.setSystemId(systemId);

        SAXSource from = new SAXSource(parser, input);
        from.setSystemId(systemId);

        ByteArrayOutputStream output = new ByteArrayOutputStream();
        StreamResult to = new StreamResult(output);

        transformer.transform(from, to);

        return output.toByteArray();
    }

    protected Templates getTransformerTemplates(Transform transform,
                                                Element errorLocation) {
        String ext = URLUtil.getExtension(transform.url);
        if (ext == null || 
            (!"xsl".equalsIgnoreCase(ext) &&
             !"xslt".equalsIgnoreCase(ext))) {
            console.error(errorLocation, 
                          Msg.msg("unsupportedTransformation", 
                                  URLUtil.toLabel(transform.url)));
            return null;
        }

        console.verbose(Msg.msg("compilingTransformation", 
                                URLUtil.toLabel(transform.url)));

        Templates templates = null;
        try {
            TransformerFactory factory = getTransformerFactory();
            console.debug(Msg.msg("usingImplementation", "TransformerFactory",
                                  factory.getClass().getName()));

            templates = factory.newTemplates(
                new StreamSource(transform.url.toExternalForm()));
        } catch (Exception e) {
            console.error(errorLocation, 
                          Msg.msg("cannotCreateTransformation", 
                                  URLUtil.toLabel(transform.url), 
                                  ThrowableUtil.reason(e)));
        }

        return templates;
    }

    protected TransformerFactory getTransformerFactory() 
        throws Exception {
        TransformerFactory factory = TransformerFactory.newInstance();

        // For use by xsl:import and xsl:include.
        factory.setURIResolver(getURIResolver());
        factory.setErrorListener(new ConsoleErrorListener(console));

        return factory;
    }

    // ---------------------------------
    // applyFilters
    // ---------------------------------

    protected boolean applyFilters(Element realized, FilterStack filterStack) {
        final FilterAttribute[] filterAttrs = filterStack.getFilterAttributes();

        boolean excluded = false;

        boolean filtered = false;
        for (FilterAttribute filterAttr : filterAttrs) {
            if (DOMUtil.getNonEmptyAttribute(realized,
                                             null, filterAttr.name) != null) {
                filtered = true;
                break;
            }
        }

        // ---

        if (filtered) {
            for (FilterAttribute filterAttr : filterAttrs) { /*loop*/
                String value =  
                    DOMUtil.getNonEmptyAttribute(realized,
                                                 null, filterAttr.name);
                if (value != null) {
                    String[] tokens = Profile.splitEffectivityValue(
                        value, EFFECTIVITY_VALUE_SEPARATOR);
                    int tokenCount;
                    if (tokens != null && (tokenCount = tokens.length) > 0) {
                        boolean exclude = false;

                        if (filterAttr.inValues.length > 0) {
                            exclude = true;
                            for (String token : tokens) {
                                if (ArrayUtil.indexOf(filterAttr.inValues,
                                                      token) >= 0) {
                                    exclude = false;
                                    break;
                                }
                            }
                        }

                        if (!exclude) {
                            if (filterAttr.outValues.length > 0) {
                                int matchingTokenCount = 0;
                                for (String token : tokens) {
                                    if (ArrayUtil.indexOf(filterAttr.outValues,
                                                          token) >= 0) {
                                        ++matchingTokenCount;
                                    }
                                }

                                if (matchingTokenCount == tokenCount) {
                                    exclude = true;
                                }
                            }
                        }

                        if (exclude) {
                            Element parent = DOMUtil.getParentElement(realized);
                            if (parent != null) {
                                parent.removeChild(realized);
                            }
                            // Otherwise, top realized module not yet attached
                            // to its parent.
                            excluded = true;
                            break; /*loop*/
                        }
                    }
                }
            }
        }

        // ---

        if (!excluded) {
            Node node = realized.getFirstChild();
            while (node != null) {
                Node next = node.getNextSibling();

                if (node.getNodeType() == Node.ELEMENT_NODE) {
                    applyFilters((Element) node, filterStack);
                }

                node = next;
            }
        }

        return excluded;
    }

    // ---------------------------------
    // normalizeInfo
    // ---------------------------------

    protected Element normalizeInfo(Element realized,
                                    boolean discardTitles,
                                    Document realizedDoc) {
        // Move title, titleabbrev, subtitle from realized element into info ---

        Element info = 
            DOMUtil.getChildElementByName(realized, DOCBOOK_NS_URI, "info");

        for (String titleName : TITLE_NAMES) {
            Element moved = 
                DOMUtil.getChildElementByName(realized,
                                              DOCBOOK_NS_URI, titleName);
            if (moved != null) {
                if (info == null) {
                    info = realizedDoc.createElementNS(DOCBOOK_NS_URI, "info");
                    realized.insertBefore(info, realized.getFirstChild());
                }
                
                realized.removeChild(moved);
                info.insertBefore(moved, info.getFirstChild());
            }
        }

        // Implementation of "omittitles" ---

        if (discardTitles && info != null) {
            // For use when implementing contentonly=true + omittitles=true.
            info.setUserData("omittitles", Boolean.TRUE, null);
            
            for (String titleName : TITLE_NAMES) {
                Element removed = 
                    DOMUtil.getChildElementByName(info,
                                                  DOCBOOK_NS_URI, titleName);
                if (removed != null) {
                    info.removeChild(removed);
                }
            }
        }

        return info;
    }

    // ----------------------------------
    // realizeRelationships
    // ----------------------------------

    protected void realizeRelationships(Document realizedDoc) {
        HashMap<String,Relationship[]> idToRelationships =
            new HashMap<String,Relationship[]>();

        HashMap<String,int[]> resourceIdToCounter = new HashMap<String,int[]>();

        for (Relationship rel : relationshipList) {
            for (Instance instance : rel.instances) {
                final String resourceId = instance.resourceId;

                Element realized = idToRealized.get(resourceId);
                if (realized != null) {
                    String realizedId =
                      DOMUtil.getNonEmptyAttribute(realized, XML_NS_URI, "id");
                    if (realizedId == null) {
                        StringBuilder buffer = new StringBuilder();
                        buffer.append("__");
                        buffer.append(resourceId);

                        int[] counter = resourceIdToCounter.get(resourceId);
                        if (counter == null) {
                            counter = new int[1];
                            resourceIdToCounter.put(resourceId, counter);
                        }
                        ++counter[0];
                        if (counter[0] >= 2) {
                            buffer.append('-');
                            buffer.append(Integer.toString(counter[0]));
                        }

                        buffer.append("__");
                        realizedId = buffer.toString();

                        realized.setAttributeNS(XML_NS_URI, "xml:id",
                                                realizedId);
                    }

                    instance.realizedId = realizedId;
                }

                // ---

                Relationship[] list = idToRelationships.get(resourceId);
                if (list == null) {
                    list = new Relationship[] { rel };
                } else {
                    list = ArrayUtil.append(list, rel);
                }
                idToRelationships.put(resourceId, list);
            }
        }

        // ---

        realizeRelationships(realizedDoc.getDocumentElement(), 
                             idToRelationships, realizedDoc);
    }

    protected void realizeRelationships(Element tree,
                                        Map<String,Relationship[]> idToRels,
                                        Document realizedDoc) {
        Node node = tree.getFirstChild();
        while (node != null) {
            Node next = node.getNextSibling();

            switch (node.getNodeType()) {
            case Node.PROCESSING_INSTRUCTION_NODE:
                {
                    ProcessingInstruction pi = (ProcessingInstruction) node;

                    if ("realize-relationships".equals(pi.getTarget())) {
                        // One resource, two possible IDs: 
                        // the resource xml:id and the module xml:id.

                        String[] resourceIds = 
                            StringUtil.split(pi.getData(), ' ');

                        Element[] sections = null;

                        for (String resourceId : resourceIds) {
                            Relationship[] rels = idToRels.get(resourceId);
                            if (rels != null) {
                                sections =
                                  createRelationshipSections(resourceId, rels,
                                                             tree, realizedDoc);
                                // Done with this resource.
                                break;
                            }
                        }

                        if (sections != null) {
                            for (Element section : sections) {
                                tree.insertBefore(section, pi);
                            }
                        }
                        tree.removeChild(pi);
                    }
                }
                break;

            case Node.ELEMENT_NODE:
                realizeRelationships((Element) node, idToRels, realizedDoc);
                break;
            }

            node = next;
        }
    }

    protected Element[] createRelationshipSections(String resourceId, 
                                                   Relationship[] relationships,
                                                   Element realizedParent,
                                                   Document realizedDoc) {
        HashMap<String,Relationship> assocToSectionRel =
            new HashMap<String,Relationship>();

        for (Relationship rel : relationships) {
            Linking resourceLinking = null;
            for (Instance instance : rel.instances) {
                if (resourceId.equals(instance.resourceId)) {
                    resourceLinking = instance.getLinking();
                    break;
                }
            }
            if (resourceLinking == Linking.NONE ||
                resourceLinking == Linking.TARGET_ONLY) {
                // Skip this rel.
                continue;
            }

            Relationship sectionRel = assocToSectionRel.get(rel.association);
            if (sectionRel == null) {
                sectionRel = new Relationship(rel.source);
                sectionRel.association = rel.association;

                assocToSectionRel.put(rel.association, sectionRel);
            }

            for (Instance instance : rel.instances) {
                if (!resourceId.equals(instance.resourceId)) {
                    switch (instance.getLinking()) {
                    case NORMAL:
                    case TARGET_ONLY:
                        if (instance.realizedId != null) {
                            sectionRel.add(instance);
                        }
                        break;
                    }
                }
            }
        }

        int count = assocToSectionRel.size();
        if (count == 0) {
            return null;
        }

        Relationship[] sectionRels = new Relationship[count];
        assocToSectionRel.values().toArray(sectionRels);
        if (count > 1) {
            Arrays.sort(sectionRels, COMPARE_BY_ASSOCIATION);
        }

        // ---

        Element[] sects = new Element[count];
        int j = 0;

        for (int i = 0; i < count; ++i) {
            Relationship sectionRel = sectionRels[i];

            if (sectionRel.instances.length == 0) {
                continue;
            }
            Element list =
                realizedDoc.createElementNS(DOCBOOK_NS_URI, "itemizedlist");
            sects[j++] = list;

            list.setAttributeNS(null, "remap", "relationship");
            list.setAttributeNS(null, "spacing", "compact");

            Element title =
                realizedDoc.createElementNS(DOCBOOK_NS_URI, "title");
            list.appendChild(title);

            title.setAttributeNS(null, "remap", "association");

            String assoc = sectionRel.association;
            if (assoc.length() == 0) {
                assoc = LocalizedText.get(
                    "relatedInformation", realizedParent, "Related Information",
                    "docbook51-config:dbasmproc/messages/messages.xml", 
                    resolver, Processor.class);
            }
            title.appendChild(realizedDoc.createTextNode(assoc));

            for (Instance instance : sectionRel.instances) {
                Element item =
                    realizedDoc.createElementNS(DOCBOOK_NS_URI, "listitem");
                list.appendChild(item);

                item.setAttributeNS(null, "remap", "instance");
            
                Element para =
                    realizedDoc.createElementNS(DOCBOOK_NS_URI, "para");
                item.appendChild(para);

                Element xref =
                    realizedDoc.createElementNS(DOCBOOK_NS_URI, "xref");;
                para.appendChild(xref);

                xref.setAttributeNS(null, "linkend", instance.realizedId);
            }
        }

        if (j == 0) {
            sects = null;
        } else if (j != count) {
            sects = ArrayUtil.trimToSize(sects, j);
        }

        return sects;
    }

    // ----------------------------------
    // XML resolver
    // ----------------------------------

    /**
     * Returns a properly configured, ready to use, EntityResolver.
     * May return <code>null</code>.
     */
    protected EntityResolver getEntityResolver() {
        return resolver;
    }

    /**
     * Returns a properly configured, ready to use, URIResolver.
     * May return <code>null</code>.
     */
    protected URIResolver getURIResolver() {
        return resolver;
    }

    /**
     * Resolves specified URI.
     * 
     * @param uri URI which is to be resolved. This URI may have a fragment.
     * @param baseURL base URL used when <code>uri</code> is relative.
     * May be <code>null</code>.
     * @return resolved URL
     * @exception MalformedURLException if <code>uri</code> cannot be resolved
     * because it is malformed
     */
    protected URL resolveURI(String uri, URL baseURL)
        throws MalformedURLException {
        String resolved = null;
        if (resolver != null) {
            resolved = resolver.resolveURI(uri);
        }

        if (resolved != null) {
            return URLUtil.createURL(resolved);
        } else {
            return URLUtil.createURL(baseURL, uri);
        }
    }

    // -----------------------------------------------------------------------
    // Main
    // -----------------------------------------------------------------------

    /**
     * Implementation of the <code>assembly</code> command-line utility.
     */
    public static void main(String[] args) {
        ConsoleHelper console = new ConsoleHelper(
            new SimpleConsole("assembly: ", true, Console.MessageType.INFO));
        Processor processor = new Processor(console);

        runMain(processor, args);
    }

    protected static void runMain(Processor processor, String[] args) {
        ConsoleHelper console = processor.getConsole();

        int l = -1;
        try {
            l = processor.configure(args);
        } catch (IllegalArgumentException e) {
            usage(console, ThrowableUtil.reason(e));
            /*NOTREACHED*/
        }

        final int argCount = args.length;
        if (l < argCount) {
            String arg = args[l];
            if ("-version".equals(arg)) {
                console.setVerbosity(Console.MessageType.INFO);
                console.info("XMLmind Assembly Processor v"+Processor.VERSION);
                // Done.
                System.exit(0);
                /*NOTREACHED*/
            } else if (arg.startsWith("-")) {
                usage(console, Msg.msg("unknownOption", arg));
                /*NOTREACHED*/
            }
        }

        if (l+2 != argCount) {
            usage(console, Msg.msg("tooFewArgs"));
            /*NOTREACHED*/
        }

        URL inURL = URLUtil.urlOrFile(args[l]);
        if (inURL == null) {
            usage(console, Msg.msg("notAnURLOrFile", args[l]));
            /*NOTREACHED*/
        }

        File outFile = null;
        if (!"-".equals(args[l+1])) {
            outFile = new File(args[l+1]);
        }

        if (System.getProperty("xml.catalog.files") != null) {
            // Works if xmlresolver.jar is in the CLASSPATH.
            try {
                XMLResolverImpl resolver = new XMLResolverImpl(/*urls*/ null);
                processor.setResolver(resolver);
            } catch (Exception ignored) {}
        }

        int exitCode = 0;
        String error = null;
        try {
            if (outFile == null) {
                if (processor.process(inURL, (URL) null) == null) {
                    exitCode = 2;
                }
            } else {
                if (!processor.process(inURL, outFile)) {
                    exitCode = 2;
                }
            }
        } catch (IOException e) {
            error = ThrowableUtil.reason(e);
            exitCode = 3;
        } catch (Exception e) {
            error = ThrowableUtil.detailedReason(e);
            exitCode = 4;
        }

        if (exitCode != 0) {
            String msg = Msg.msg("cannotProcess", URLUtil.toLabel(inURL));
            if (error != null) {
                msg += ":\n" + error;
            }
            console.error(msg);
        }

        System.exit(exitCode);
    }

    private static void usage(ConsoleHelper console, String error) {
        String msg = Msg.msg("usage");
        if (error != null) {
            msg = error + "\n" + msg;
        }
        console.error(msg);
        System.exit(1);
    }
}
