/******************************************************************************* * Copyright (c) 2007, 2013 David Green and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * David Green - initial API and implementation *******************************************************************************/ package org.eclipse.mylyn.wikitext.parser.builder; import java.io.Writer; import java.util.HashSet; import java.util.Set; import java.util.Stack; import java.util.logging.Logger; import org.eclipse.mylyn.wikitext.parser.Attributes; import org.eclipse.mylyn.wikitext.parser.ImageAttributes; import org.eclipse.mylyn.wikitext.parser.LinkAttributes; import org.eclipse.mylyn.wikitext.parser.outline.OutlineItem; import org.eclipse.mylyn.wikitext.util.FormattingXMLStreamWriter; import org.eclipse.mylyn.wikitext.util.XmlStreamWriter; /** * A document builder that creates an OASIS DITA topic * * @author David Green * @see DitaBookMapDocumentBuilder * @since 3.0 */ public class DitaTopicDocumentBuilder extends AbstractXmlDocumentBuilder { private static final String __TOPIC = "__topic"; //$NON-NLS-1$ private static Set<Integer> entityReferenceToUnicode = new HashSet<Integer>(); static { entityReferenceToUnicode.add(215); entityReferenceToUnicode.add(8211); entityReferenceToUnicode.add(8212); entityReferenceToUnicode.add(8220); entityReferenceToUnicode.add(8221); entityReferenceToUnicode.add(8216); entityReferenceToUnicode.add(8217); } private final Stack<BlockDescription> blockDescriptions = new Stack<BlockDescription>(); private String doctype = "<!DOCTYPE topic PUBLIC \"-//OASIS//DTD DITA 1.1 Topic//EN\" \"http://docs.oasis-open.org/dita/v1.1/OS/dtd/topic.dtd\">"; //$NON-NLS-1$ private static class TopicInfo { int headingLevel; int openElements; } private final Stack<TopicInfo> topicInfos = new Stack<TopicInfo>(); private OutlineItem outline; private String filename; private int topicBreakLevel = Integer.MAX_VALUE; private String rootTitle; /** * Create a DitaTopicDocumentBuilder that writes formatted output to the given writer. Output without formatting can * be created using {@link #DitaTopicDocumentBuilder(XmlStreamWriter, boolean)}. * * @param out * the writer to which formatted XML content output */ public DitaTopicDocumentBuilder(Writer out) { super(out); } /** * Equivalent to <code>new DitaTopicDocumentBuilder(writer,true)</code> * * @see #DitaTopicDocumentBuilder(XmlStreamWriter, boolean) */ public DitaTopicDocumentBuilder(XmlStreamWriter writer) { this(writer, true); } /** * @param writer * the writer to which output is written * @param formatting * indicate if the writer should format output */ public DitaTopicDocumentBuilder(XmlStreamWriter writer, boolean formatting) { super(formatting ? wrapStreamWriter(writer) : writer); } @Override protected XmlStreamWriter createXmlStreamWriter(Writer out) { XmlStreamWriter writer = super.createXmlStreamWriter(out); return wrapStreamWriter(writer); } /** * wrap the stream writer in order to produce formatted output */ private static FormattingXMLStreamWriter wrapStreamWriter(XmlStreamWriter writer) { return new FormattingXMLStreamWriter(writer) { @Override protected boolean preserveWhitespace(String elementName) { return elementName.equals("codeblock") || elementName.startsWith("pre"); //$NON-NLS-1$ //$NON-NLS-2$ } }; } public void setDoctype(String doctype) { this.doctype = doctype; } public String getDoctype() { return doctype; } @Override public void acronym(String text, String definition) { ensureOpenTopic(); // TODO: definition? according to DITA 1.1 'term' is the right thing to use here, however DITA 1.1 has no provision for a glossary. // we may want to look at reference/refbody/simpletable to generate a glossary writer.writeStartElement("term"); //$NON-NLS-1$ characters(text); writer.writeEndElement(); } private BlockDescription findBlockDescription(BlockType type) { for (int x = blockDescriptions.size() - 1; x >= 0; --x) { BlockDescription blockDescription = blockDescriptions.get(x); if (blockDescription.type == type) { return blockDescription; } } return null; } private static class BlockDescription { BlockType type; int size; int entrySize; // the size of an entry, if it is open, otherwise 0 @SuppressWarnings("unused") final String[] nestedElementNames; final boolean closeElementsOnBlockStart; public BlockDescription(BlockType type, int size, String[] nestedElementNames, boolean closeElementsOnBlockStart) { this.size = size; this.entrySize = nestedElementNames == null ? 0 : nestedElementNames.length; this.type = type; this.nestedElementNames = nestedElementNames; this.closeElementsOnBlockStart = closeElementsOnBlockStart; } } @Override public void beginBlock(BlockType type, Attributes attributes) { ensureOpenTopic(); String elementName; String[] elementNames = null; boolean allowTitle = false; boolean closeElementsOnBlockStart = false; BlockDescription previousBlock = null; if (!blockDescriptions.isEmpty()) { previousBlock = blockDescriptions.peek(); } boolean phraseTitle = false; switch (type) { case BULLETED_LIST: elementName = "ul"; //$NON-NLS-1$ break; case NUMERIC_LIST: elementName = "ol"; //$NON-NLS-1$ break; case DEFINITION_LIST: elementName = "dl"; //$NON-NLS-1$ break; case DEFINITION_TERM: BlockDescription blockDescription = findBlockDescription(BlockType.DEFINITION_LIST); if (blockDescription.entrySize > 0) { endBlockEntry(blockDescription); } openBlockEntry(blockDescription, new String[] { "dlentry" }); //$NON-NLS-1$ elementName = "dt"; //$NON-NLS-1$ break; case DEFINITION_ITEM: elementName = "dd"; //$NON-NLS-1$ elementNames = new String[] { "p" }; //$NON-NLS-1$ closeElementsOnBlockStart = true; break; case FOOTNOTE: case PARAGRAPH: elementName = "p"; //$NON-NLS-1$ break; case CODE: elementName = "pre"; //$NON-NLS-1$ elementNames = new String[] { "codeph" }; //$NON-NLS-1$ break; case PREFORMATTED: elementName = "pre"; //$NON-NLS-1$ break; case QUOTE: elementName = "lq"; //$NON-NLS-1$ break; case LIST_ITEM: elementName = "li"; //$NON-NLS-1$ elementNames = new String[] { "p" }; //$NON-NLS-1$ closeElementsOnBlockStart = true; break; case TABLE: elementName = "simpletable"; //$NON-NLS-1$ break; case TABLE_CELL_HEADER: // TODO: no such thing as header cells in DITA, only header rows // need a way to detect beforehand if we're about to emit a header row elementName = "stentry"; //$NON-NLS-1$ break; case TABLE_CELL_NORMAL: elementName = "stentry"; //$NON-NLS-1$ break; case TABLE_ROW: elementName = "strow"; //$NON-NLS-1$ break; case INFORMATION: case NOTE: case WARNING: case TIP: case PANEL: elementName = "note"; //$NON-NLS-1$ allowTitle = true; phraseTitle = true; break; case DIV: elementName = null; break; default: throw new IllegalStateException(type.name()); } int blockSize; if (elementName != null) { blockSize = 1; if (previousBlock != null && previousBlock.closeElementsOnBlockStart) { endBlockEntry(previousBlock); } writer.writeStartElement(elementName); switch (type) { case INFORMATION: writer.writeAttribute("type", "important"); //$NON-NLS-1$ //$NON-NLS-2$ break; case NOTE: writer.writeAttribute("type", "note"); //$NON-NLS-1$ //$NON-NLS-2$ break; case WARNING: writer.writeAttribute("type", "caution"); //$NON-NLS-1$ //$NON-NLS-2$ break; case TIP: writer.writeAttribute("type", "tip"); //$NON-NLS-1$ //$NON-NLS-2$ break; case PANEL: writer.writeAttribute("type", "other"); //$NON-NLS-1$ //$NON-NLS-2$ break; } applyAttributes(attributes); if (elementNames != null) { for (String name : elementNames) { writer.writeStartElement(name); } } if (allowTitle && attributes.getTitle() != null) { if (phraseTitle) { writer.writeStartElement("ph"); //$NON-NLS-1$ writer.writeAttribute("outputclass", "title"); //$NON-NLS-1$ //$NON-NLS-2$ writer.writeCharacters(attributes.getTitle()); writer.writeEndElement(); } else { writer.writeStartElement("title"); //$NON-NLS-1$ writer.writeCharacters(attributes.getTitle()); writer.writeEndElement(); } } } else { blockSize = 0; } blockDescriptions.push(new BlockDescription(type, blockSize, elementNames, closeElementsOnBlockStart)); } @Override public void endBlock() { final BlockDescription blockDescription = blockDescriptions.pop(); int size = blockDescription.size + blockDescription.entrySize; for (int x = 0; x < size; ++x) { writer.writeEndElement(); } } private void endBlockEntry(BlockDescription blockDescription) { for (int x = 0; x < blockDescription.entrySize; ++x) { writer.writeEndElement(); } blockDescription.entrySize = 0; } private void openBlockEntry(BlockDescription blockDescription, String[] entry) { for (String ent : entry) { writer.writeStartElement(ent); } blockDescription.entrySize += entry.length; } @Override public void beginDocument() { writer.writeStartDocument(); writer.writeDTD(doctype); if (rootTitle != null) { writer.writeStartElement("topic"); //$NON-NLS-1$ writer.writeStartElement("title"); //$NON-NLS-1$ writer.writeCharacters(rootTitle); writer.writeEndElement(); } } @Override public void beginHeading(int level, Attributes attributes) { closeTopics(Math.max(level - 1, 0)); if (topicInfos.isEmpty() || topicInfos.peek().headingLevel < level) { TopicInfo topicInfo = new TopicInfo(); topicInfo.headingLevel = level; topicInfo.openElements = 2; topicInfos.push(topicInfo); writer.writeStartElement("topic"); //$NON-NLS-1$ if (attributes != null) { applyAttributes(attributes); attributes = null; } writer.writeStartElement("title"); //$NON-NLS-1$ } } private void applyAttributes(Attributes attributes) { if (attributes.getId() != null) { writer.writeAttribute("id", attributes.getId()); //$NON-NLS-1$ } if (attributes.getCssClass() != null) { writer.writeAttribute("outputclass", attributes.getCssClass()); //$NON-NLS-1$ } } @Override public void beginSpan(SpanType type, Attributes attributes) { ensureOpenTopic(); switch (type) { case BOLD: case STRONG: writer.writeStartElement("b"); //$NON-NLS-1$ break; case CITATION: writer.writeStartElement("cite"); //$NON-NLS-1$ break; case CODE: writer.writeStartElement("codeph"); //$NON-NLS-1$ break; case DELETED: // no equivalent? writer.writeStartElement("ph"); //$NON-NLS-1$ attributes .setCssClass(attributes.getCssClass() == null ? "deleted" : attributes.getCssClass() + " deleted"); //$NON-NLS-1$ //$NON-NLS-2$ break; case EMPHASIS: writer.writeStartElement("i"); //$NON-NLS-1$ break; case INSERTED: // no equivalent? writer.writeStartElement("ph"); //$NON-NLS-1$ attributes.setCssClass(attributes.getCssClass() == null ? "inserted" : attributes.getCssClass() //$NON-NLS-1$ + " inserted"); //$NON-NLS-1$ break; case UNDERLINED: writer.writeStartElement("u"); //$NON-NLS-1$ break; case ITALIC: writer.writeStartElement("i"); //$NON-NLS-1$ break; case SPAN: writer.writeStartElement("ph"); //$NON-NLS-1$ break; case SUBSCRIPT: writer.writeStartElement("sub"); //$NON-NLS-1$ break; case SUPERSCRIPT: writer.writeStartElement("sup"); //$NON-NLS-1$ break; case MONOSPACE: writer.writeStartElement("tt"); //$NON-NLS-1$ break; case QUOTE: writer.writeStartElement("q"); //$NON-NLS-1$ break; case LINK: { LinkAttributes linkAttributes = (LinkAttributes) attributes; writer.writeStartElement("xref"); //$NON-NLS-1$ writer.writeAttribute("href", computeDitaXref(linkAttributes.getHref())); //$NON-NLS-1$ } break; default: Logger.getLogger(DocBookDocumentBuilder.class.getName()).warning("No DITA topic mapping for " + type); //$NON-NLS-1$ writer.writeStartElement("ph"); //$NON-NLS-1$ break; } applyAttributes(attributes); } @Override public void endSpan() { writer.writeEndElement(); } @Override public void charactersUnescaped(String literal) { ensureOpenTopic(); // note: this *may* have HTML tags in it writer.writeLiteral(literal); } private void ensureOpenTopic() { if (topicInfos.isEmpty()) { beginHeading(1, new Attributes()); endHeading(); } } private void closeTopics(int toLevel) { if (toLevel < 0) { toLevel = 0; } while (!topicInfos.isEmpty() && topicInfos.peek().headingLevel > toLevel) { TopicInfo topicInfo = topicInfos.pop(); for (int x = 0; x < topicInfo.openElements; ++x) { writer.writeEndElement(); } } if (!topicInfos.isEmpty()) { TopicInfo topicInfo = topicInfos.peek(); while (topicInfo.openElements > 1) { --topicInfo.openElements; writer.writeEndElement(); } } } @Override public void endDocument() { closeTopics(0); if (rootTitle != null) { writer.writeEndElement(); } writer.writeEndDocument(); } @Override public void endHeading() { writer.writeEndElement(); // title writer.writeStartElement("body"); //$NON-NLS-1$ } @Override public void image(Attributes attributes, String url) { ensureOpenTopic(); boolean emitAsFigure = attributes.getTitle() != null; if (emitAsFigure) { writer.writeStartElement("fig"); //$NON-NLS-1$ writer.writeStartElement("title");//$NON-NLS-1$ writer.writeCharacters(attributes.getTitle()); writer.writeEndElement(); } writer.writeEmptyElement("image"); //$NON-NLS-1$ writer.writeAttribute("href", url); //$NON-NLS-1$ applyImageAttributes(attributes); if (emitAsFigure) { writer.writeEndElement(); } } @Override public void imageLink(Attributes linkAttributes, Attributes imageAttributes, String href, String imageUrl) { ensureOpenTopic(); writer.writeStartElement("xref"); //$NON-NLS-1$ writer.writeAttribute("href", computeDitaXref(href)); //$NON-NLS-1$ writer.writeAttribute("format", "html"); //$NON-NLS-1$ //$NON-NLS-2$ image(imageAttributes, imageUrl); writer.writeEndElement(); } private void applyImageAttributes(Attributes imageAttributes) { applyAttributes(imageAttributes); if (imageAttributes instanceof ImageAttributes) { ImageAttributes attributes = (ImageAttributes) imageAttributes; if (attributes.getAlt() != null) { writer.writeAttribute("alt", attributes.getAlt()); //$NON-NLS-1$ } if (attributes.getHeight() > 0) { writer.writeAttribute("height", Integer.toString(attributes.getHeight())); //$NON-NLS-1$ } if (attributes.getWidth() > 0) { writer.writeAttribute("width", Integer.toString(attributes.getWidth())); //$NON-NLS-1$ } if (attributes.getAlign() != null) { switch (attributes.getAlign()) { case Left: writer.writeAttribute("align", "center"); //$NON-NLS-1$ //$NON-NLS-2$ break; case Right: writer.writeAttribute("align", "right"); //$NON-NLS-1$ //$NON-NLS-2$ break; case Center: writer.writeAttribute("align", "center"); //$NON-NLS-1$ //$NON-NLS-2$ break; } writer.writeAttribute("placement", "break"); //$NON-NLS-1$ //$NON-NLS-2$ } } } @Override public void lineBreak() { // no equivalent in DITA? } @Override public void link(Attributes attributes, String hrefOrHashName, String text) { ensureOpenTopic(); writer.writeStartElement("xref"); //$NON-NLS-1$ writer.writeAttribute("href", computeDitaXref(hrefOrHashName)); //$NON-NLS-1$ if (text != null) { characters(text); } writer.writeEndElement(); } @Override public void characters(String text) { ensureOpenTopic(); super.characters(text); } @Override public void entityReference(String entity) { ensureOpenTopic(); if (entity.startsWith("#")) { //$NON-NLS-1$ String numeric = entity.substring(1); int base = 10; if (numeric.startsWith("x")) { //$NON-NLS-1$ numeric = entity.substring(1); base = 16; } int unicodeValue = Integer.parseInt(numeric, base); if (entityReferenceToUnicode.contains(unicodeValue)) { writer.writeCharacters("" + ((char) unicodeValue)); //$NON-NLS-1$ return; } } writer.writeEntityRef(entity); } /** * the outline if available, otherwise null {@link #setOutline(OutlineItem)} */ public OutlineItem getOutline() { return outline; } /** * Set the outline of the document being parsed if xref URLs are to be correctly computed. OASIS DITA has its own * URL syntax for DITA-specific links, which need some translation at the time that we build the document. */ public void setOutline(OutlineItem outline) { this.outline = outline; } public String getFilename() { return filename; } public void setFilename(String filename) { this.filename = filename; } /** * According to the DITA documentation, DITA content URLs use special syntax. this method translates internal URLs * correctly according to the DITA rules. * * @return the href adjusted, or the original href if the given URL appears to be to non-document content */ private String computeDitaXref(String href) { if (href.startsWith("#") && topicBreakLevel < Integer.MAX_VALUE) { //$NON-NLS-1$ if (outline != null) { OutlineItem item = outline.findItemById(href.substring(1)); if (item != null) { OutlineItem topicItem = computeTopicFileItem(item); String targetFilename = computeTargetFilename(topicItem); String ref; if (targetFilename.equals(filename)) { ref = href; } else { ref = targetFilename + href; } return ref; } } } return href; } public static String computeName(String headingId, String topicFilenameSuffix) { String name = headingId == null ? __TOPIC : headingId.replaceAll("[^a-zA-Z0-9_.-]", "-"); //$NON-NLS-1$ //$NON-NLS-2$ name = name + topicFilenameSuffix; return name; } private String computeTargetFilename(OutlineItem item) { String filenameSuffix = filename.substring(filename.lastIndexOf('.')); return computeName(item.getLevel() == topicBreakLevel ? item.getId() : null, filenameSuffix); } private OutlineItem computeTopicFileItem(OutlineItem item) { while (item.getLevel() > topicBreakLevel && item.getParent() != null && item.getParent().getLevel() > (topicBreakLevel - 1)) { item = item.getParent(); } return item; } /** * the heading level at which topics are determined */ public int getTopicBreakLevel() { return topicBreakLevel; } /** * the heading level at which topics are determined */ public void setTopicBreakLevel(int topicBreakLevel) { this.topicBreakLevel = topicBreakLevel; } /** * The title of the root topic if there should be one. If specified, the topic file is created with a 'wrapper' root * topic with the given title. */ public void setRootTopicTitle(String rootTitle) { this.rootTitle = rootTitle; } /** * The title of the root topic if there should be one. If specified, the topic file is created with a 'wrapper' root * topic with the given title. */ public String getRootTopicTitle() { return rootTitle; } }