| /**************************************************************************** |
| ** |
| ** Copyright (C) 2019 The Qt Company Ltd. |
| ** Contact: https://www.qt.io/licensing/ |
| ** |
| ** This file is part of the QtGui module of the Qt Toolkit. |
| ** |
| ** $QT_BEGIN_LICENSE:LGPL$ |
| ** Commercial License Usage |
| ** Licensees holding valid commercial Qt licenses may use this file in |
| ** accordance with the commercial license agreement provided with the |
| ** Software or, alternatively, in accordance with the terms contained in |
| ** a written agreement between you and The Qt Company. For licensing terms |
| ** and conditions see https://www.qt.io/terms-conditions. For further |
| ** information use the contact form at https://www.qt.io/contact-us. |
| ** |
| ** GNU Lesser General Public License Usage |
| ** Alternatively, this file may be used under the terms of the GNU Lesser |
| ** General Public License version 3 as published by the Free Software |
| ** Foundation and appearing in the file LICENSE.LGPL3 included in the |
| ** packaging of this file. Please review the following information to |
| ** ensure the GNU Lesser General Public License version 3 requirements |
| ** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. |
| ** |
| ** GNU General Public License Usage |
| ** Alternatively, this file may be used under the terms of the GNU |
| ** General Public License version 2.0 or (at your option) the GNU General |
| ** Public license version 3 or any later version approved by the KDE Free |
| ** Qt Foundation. The licenses are as published by the Free Software |
| ** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 |
| ** included in the packaging of this file. Please review the following |
| ** information to ensure the GNU General Public License requirements will |
| ** be met: https://www.gnu.org/licenses/gpl-2.0.html and |
| ** https://www.gnu.org/licenses/gpl-3.0.html. |
| ** |
| ** $QT_END_LICENSE$ |
| ** |
| ****************************************************************************/ |
| |
| #include "qtextmarkdownwriter_p.h" |
| #include "qtextdocumentlayout_p.h" |
| #include "qfontinfo.h" |
| #include "qfontmetrics.h" |
| #include "qtextdocument_p.h" |
| #include "qtextlist.h" |
| #include "qtexttable.h" |
| #include "qtextcursor.h" |
| #include "qtextimagehandler_p.h" |
| #include "qloggingcategory.h" |
| #if QT_CONFIG(itemmodel) |
| #include "qabstractitemmodel.h" |
| #endif |
| |
| QT_BEGIN_NAMESPACE |
| |
| Q_LOGGING_CATEGORY(lcMDW, "qt.text.markdown.writer") |
| |
| static const QChar Space = QLatin1Char(' '); |
| static const QChar Newline = QLatin1Char('\n'); |
| static const QChar LineBreak = QChar(0x2028); |
| static const QChar DoubleQuote = QLatin1Char('"'); |
| static const QChar Backtick = QLatin1Char('`'); |
| static const QChar Period = QLatin1Char('.'); |
| |
| QTextMarkdownWriter::QTextMarkdownWriter(QTextStream &stream, QTextDocument::MarkdownFeatures features) |
| : m_stream(stream), m_features(features) |
| { |
| } |
| |
| bool QTextMarkdownWriter::writeAll(const QTextDocument *document) |
| { |
| writeFrame(document->rootFrame()); |
| return true; |
| } |
| |
| #if QT_CONFIG(itemmodel) |
| void QTextMarkdownWriter::writeTable(const QAbstractItemModel *table) |
| { |
| QVector<int> tableColumnWidths(table->columnCount()); |
| for (int col = 0; col < table->columnCount(); ++col) { |
| tableColumnWidths[col] = table->headerData(col, Qt::Horizontal).toString().length(); |
| for (int row = 0; row < table->rowCount(); ++row) { |
| tableColumnWidths[col] = qMax(tableColumnWidths[col], |
| table->data(table->index(row, col)).toString().length()); |
| } |
| } |
| |
| // write the header and separator |
| for (int col = 0; col < table->columnCount(); ++col) { |
| QString s = table->headerData(col, Qt::Horizontal).toString(); |
| m_stream << "|" << s << QString(tableColumnWidths[col] - s.length(), Space); |
| } |
| m_stream << "|" << Qt::endl; |
| for (int col = 0; col < tableColumnWidths.length(); ++col) |
| m_stream << '|' << QString(tableColumnWidths[col], QLatin1Char('-')); |
| m_stream << '|'<< Qt::endl; |
| |
| // write the body |
| for (int row = 0; row < table->rowCount(); ++row) { |
| for (int col = 0; col < table->columnCount(); ++col) { |
| QString s = table->data(table->index(row, col)).toString(); |
| m_stream << "|" << s << QString(tableColumnWidths[col] - s.length(), Space); |
| } |
| m_stream << '|'<< Qt::endl; |
| } |
| m_listInfo.clear(); |
| } |
| #endif |
| |
| void QTextMarkdownWriter::writeFrame(const QTextFrame *frame) |
| { |
| Q_ASSERT(frame); |
| const QTextTable *table = qobject_cast<const QTextTable*> (frame); |
| QTextFrame::iterator iterator = frame->begin(); |
| QTextFrame *child = nullptr; |
| int tableRow = -1; |
| bool lastWasList = false; |
| QVector<int> tableColumnWidths; |
| if (table) { |
| tableColumnWidths.resize(table->columns()); |
| for (int col = 0; col < table->columns(); ++col) { |
| for (int row = 0; row < table->rows(); ++ row) { |
| QTextTableCell cell = table->cellAt(row, col); |
| int cellTextLen = 0; |
| auto it = cell.begin(); |
| while (it != cell.end()) { |
| QTextBlock block = it.currentBlock(); |
| if (block.isValid()) |
| cellTextLen += block.text().length(); |
| ++it; |
| } |
| if (cell.columnSpan() == 1 && tableColumnWidths[col] < cellTextLen) |
| tableColumnWidths[col] = cellTextLen; |
| } |
| } |
| } |
| while (!iterator.atEnd()) { |
| if (iterator.currentFrame() && child != iterator.currentFrame()) |
| writeFrame(iterator.currentFrame()); |
| else { // no frame, it's a block |
| QTextBlock block = iterator.currentBlock(); |
| // Look ahead and detect some cases when we should |
| // suppress needless blank lines, when there will be a big change in block format |
| bool nextIsDifferent = false; |
| bool ending = false; |
| { |
| QTextFrame::iterator next = iterator; |
| ++next; |
| if (next.atEnd()) { |
| nextIsDifferent = true; |
| ending = true; |
| } else { |
| QTextBlockFormat format = iterator.currentBlock().blockFormat(); |
| QTextBlockFormat nextFormat = next.currentBlock().blockFormat(); |
| if (nextFormat.indent() != format.indent() || |
| nextFormat.property(QTextFormat::BlockCodeLanguage) != format.property(QTextFormat::BlockCodeLanguage)) |
| nextIsDifferent = true; |
| } |
| } |
| if (table) { |
| QTextTableCell cell = table->cellAt(block.position()); |
| if (tableRow < cell.row()) { |
| if (tableRow == 0) { |
| m_stream << Newline; |
| for (int col = 0; col < tableColumnWidths.length(); ++col) |
| m_stream << '|' << QString(tableColumnWidths[col], QLatin1Char('-')); |
| m_stream << '|'; |
| } |
| m_stream << Newline << "|"; |
| tableRow = cell.row(); |
| } |
| } else if (!block.textList()) { |
| if (lastWasList) |
| m_stream << Newline; |
| } |
| int endingCol = writeBlock(block, !table, table && tableRow == 0, |
| nextIsDifferent && !block.textList()); |
| m_doubleNewlineWritten = false; |
| if (table) { |
| QTextTableCell cell = table->cellAt(block.position()); |
| int paddingLen = -endingCol; |
| int spanEndCol = cell.column() + cell.columnSpan(); |
| for (int col = cell.column(); col < spanEndCol; ++col) |
| paddingLen += tableColumnWidths[col]; |
| if (paddingLen > 0) |
| m_stream << QString(paddingLen, Space); |
| for (int col = cell.column(); col < spanEndCol; ++col) |
| m_stream << "|"; |
| } else if (m_fencedCodeBlock && ending) { |
| m_stream << m_linePrefix << QString(m_wrappedLineIndent, Space) |
| << m_codeBlockFence << Newline << Newline; |
| m_codeBlockFence.clear(); |
| } else if (m_indentedCodeBlock && nextIsDifferent) { |
| m_stream << Newline; |
| } else if (endingCol > 0) { |
| if (block.textList() || block.blockFormat().hasProperty(QTextFormat::BlockCodeLanguage)) { |
| m_stream << Newline; |
| } else { |
| m_stream << Newline << Newline; |
| m_doubleNewlineWritten = true; |
| } |
| } |
| lastWasList = block.textList(); |
| } |
| child = iterator.currentFrame(); |
| ++iterator; |
| } |
| if (table) { |
| m_stream << Newline << Newline; |
| m_doubleNewlineWritten = true; |
| } |
| m_listInfo.clear(); |
| } |
| |
| QTextMarkdownWriter::ListInfo QTextMarkdownWriter::listInfo(QTextList *list) |
| { |
| if (!m_listInfo.contains(list)) { |
| // decide whether this list is loose or tight |
| ListInfo info; |
| info.loose = false; |
| if (list->count() > 1) { |
| QTextBlock first = list->item(0); |
| QTextBlock last = list->item(list->count() - 1); |
| QTextBlock next = first.next(); |
| while (next.isValid()) { |
| if (next == last) |
| break; |
| qCDebug(lcMDW) << "next block in list" << list << next.text() << "part of list?" << next.textList(); |
| if (!next.textList()) { |
| // If we find a continuation paragraph, this list is "loose" |
| // because it will need a blank line to separate that paragraph. |
| qCDebug(lcMDW) << "decided list beginning with" << first.text() << "is loose after" << next.text(); |
| info.loose = true; |
| break; |
| } |
| next = next.next(); |
| } |
| } |
| m_listInfo.insert(list, info); |
| return info; |
| } |
| return m_listInfo.value(list); |
| } |
| |
| static int nearestWordWrapIndex(const QString &s, int before) |
| { |
| before = qMin(before, s.length()); |
| int fragBegin = qMax(before - 15, 0); |
| if (lcMDW().isDebugEnabled()) { |
| QString frag = s.mid(fragBegin, 30); |
| qCDebug(lcMDW) << frag << before; |
| qCDebug(lcMDW) << QString(before - fragBegin, Period) + QLatin1Char('<'); |
| } |
| for (int i = before - 1; i >= 0; --i) { |
| if (s.at(i).isSpace()) { |
| qCDebug(lcMDW) << QString(i - fragBegin, Period) + QLatin1Char('^') << i; |
| return i; |
| } |
| } |
| qCDebug(lcMDW, "not possible"); |
| return -1; |
| } |
| |
| static int adjacentBackticksCount(const QString &s) |
| { |
| int start = -1, len = s.length(); |
| int ret = 0; |
| for (int i = 0; i < len; ++i) { |
| if (s.at(i) == Backtick) { |
| if (start < 0) |
| start = i; |
| } else if (start >= 0) { |
| ret = qMax(ret, i - start); |
| start = -1; |
| } |
| } |
| if (s.at(len - 1) == Backtick) |
| ret = qMax(ret, len - start); |
| return ret; |
| } |
| |
| static void maybeEscapeFirstChar(QString &s) |
| { |
| QString sTrimmed = s.trimmed(); |
| if (sTrimmed.isEmpty()) |
| return; |
| char firstChar = sTrimmed.at(0).toLatin1(); |
| if (firstChar == '*' || firstChar == '+' || firstChar == '-') { |
| int i = s.indexOf(QLatin1Char(firstChar)); |
| s.insert(i, QLatin1Char('\\')); |
| } |
| } |
| |
| int QTextMarkdownWriter::writeBlock(const QTextBlock &block, bool wrap, bool ignoreFormat, bool ignoreEmpty) |
| { |
| if (block.text().isEmpty() && ignoreEmpty) |
| return 0; |
| const int ColumnLimit = 80; |
| QTextBlockFormat blockFmt = block.blockFormat(); |
| bool missedBlankCodeBlockLine = false; |
| if (block.textList()) { // it's a list-item |
| auto fmt = block.textList()->format(); |
| const int listLevel = fmt.indent(); |
| const int number = block.textList()->itemNumber(block) + 1; |
| QByteArray bullet = " "; |
| bool numeric = false; |
| switch (fmt.style()) { |
| case QTextListFormat::ListDisc: |
| bullet = "-"; |
| m_wrappedLineIndent = 2; |
| break; |
| case QTextListFormat::ListCircle: |
| bullet = "*"; |
| m_wrappedLineIndent = 2; |
| break; |
| case QTextListFormat::ListSquare: |
| bullet = "+"; |
| m_wrappedLineIndent = 2; |
| break; |
| case QTextListFormat::ListStyleUndefined: break; |
| case QTextListFormat::ListDecimal: |
| case QTextListFormat::ListLowerAlpha: |
| case QTextListFormat::ListUpperAlpha: |
| case QTextListFormat::ListLowerRoman: |
| case QTextListFormat::ListUpperRoman: |
| numeric = true; |
| m_wrappedLineIndent = 4; |
| break; |
| } |
| switch (blockFmt.marker()) { |
| case QTextBlockFormat::MarkerType::Checked: |
| bullet += " [x]"; |
| break; |
| case QTextBlockFormat::MarkerType::Unchecked: |
| bullet += " [ ]"; |
| break; |
| default: |
| break; |
| } |
| int indentFirstLine = (listLevel - 1) * (numeric ? 4 : 2); |
| m_wrappedLineIndent += indentFirstLine; |
| if (m_lastListIndent != listLevel && !m_doubleNewlineWritten && listInfo(block.textList()).loose) |
| m_stream << Newline; |
| m_lastListIndent = listLevel; |
| QString prefix(indentFirstLine, Space); |
| if (numeric) { |
| QString suffix = fmt.numberSuffix(); |
| if (suffix.isEmpty()) |
| suffix = QString(Period); |
| QString numberStr = QString::number(number) + suffix + Space; |
| if (numberStr.length() == 3) |
| numberStr += Space; |
| prefix += numberStr; |
| } else { |
| prefix += QLatin1String(bullet) + Space; |
| } |
| m_stream << prefix; |
| } else if (blockFmt.hasProperty(QTextFormat::BlockTrailingHorizontalRulerWidth)) { |
| m_stream << "- - -\n"; // unambiguous horizontal rule, not an underline under a heading |
| return 0; |
| } else if (blockFmt.hasProperty(QTextFormat::BlockCodeFence) || blockFmt.stringProperty(QTextFormat::BlockCodeLanguage).length() > 0) { |
| // It's important to preserve blank lines in code blocks. But blank lines in code blocks |
| // inside block quotes are getting preserved anyway (along with the "> " prefix). |
| if (!blockFmt.hasProperty(QTextFormat::BlockQuoteLevel)) |
| missedBlankCodeBlockLine = true; // only if we don't get any fragments below |
| if (!m_fencedCodeBlock) { |
| QString fenceChar = blockFmt.stringProperty(QTextFormat::BlockCodeFence); |
| if (fenceChar.isEmpty()) |
| fenceChar = QLatin1String("`"); |
| m_codeBlockFence = QString(3, fenceChar.at(0)); |
| // A block quote can contain an indented code block, but not vice-versa. |
| m_stream << m_linePrefix << QString(m_wrappedLineIndent, Space) << m_codeBlockFence |
| << Space << blockFmt.stringProperty(QTextFormat::BlockCodeLanguage) << Newline; |
| m_fencedCodeBlock = true; |
| } |
| } else if (!blockFmt.indent()) { |
| if (m_fencedCodeBlock) { |
| m_stream << m_linePrefix << QString(m_wrappedLineIndent, Space) |
| << m_codeBlockFence << Newline; |
| m_fencedCodeBlock = false; |
| m_codeBlockFence.clear(); |
| } |
| m_wrappedLineIndent = 0; |
| m_linePrefix.clear(); |
| if (blockFmt.hasProperty(QTextFormat::BlockQuoteLevel)) { |
| int level = blockFmt.intProperty(QTextFormat::BlockQuoteLevel); |
| QString quoteMarker = QStringLiteral("> "); |
| m_linePrefix.reserve(level * 2); |
| for (int i = 0; i < level; ++i) |
| m_linePrefix += quoteMarker; |
| } |
| if (blockFmt.hasProperty(QTextFormat::BlockCodeLanguage)) { |
| // A block quote can contain an indented code block, but not vice-versa. |
| m_linePrefix += QString(4, Space); |
| m_indentedCodeBlock = true; |
| } |
| } |
| if (blockFmt.headingLevel()) |
| m_stream << QByteArray(blockFmt.headingLevel(), '#') << ' '; |
| else |
| m_stream << m_linePrefix; |
| |
| QString wrapIndentString = m_linePrefix + QString(m_wrappedLineIndent, Space); |
| // It would be convenient if QTextStream had a lineCharPos() accessor, |
| // to keep track of how many characters (not bytes) have been written on the current line, |
| // but it doesn't. So we have to keep track with this col variable. |
| int col = wrapIndentString.length(); |
| bool mono = false; |
| bool startsOrEndsWithBacktick = false; |
| bool bold = false; |
| bool italic = false; |
| bool underline = false; |
| bool strikeOut = false; |
| QString backticks(Backtick); |
| for (QTextBlock::Iterator frag = block.begin(); !frag.atEnd(); ++frag) { |
| missedBlankCodeBlockLine = false; |
| QString fragmentText = frag.fragment().text(); |
| while (fragmentText.endsWith(Newline)) |
| fragmentText.chop(1); |
| if (block.textList()) { // <li>first line</br>continuation</li> |
| QString newlineIndent = QString(Newline) + QString(m_wrappedLineIndent, Space); |
| fragmentText.replace(QString(LineBreak), newlineIndent); |
| } else if (blockFmt.indent() > 0) { // <li>first line<p>continuation</p></li> |
| m_stream << QString(m_wrappedLineIndent, Space); |
| } else { |
| fragmentText.replace(LineBreak, Newline); |
| } |
| startsOrEndsWithBacktick |= fragmentText.startsWith(Backtick) || fragmentText.endsWith(Backtick); |
| QTextCharFormat fmt = frag.fragment().charFormat(); |
| if (fmt.isImageFormat()) { |
| QTextImageFormat ifmt = fmt.toImageFormat(); |
| QString desc = ifmt.stringProperty(QTextFormat::ImageAltText); |
| if (desc.isEmpty()) |
| desc = QLatin1String("image"); |
| QString s = QLatin1String("![") + desc + QLatin1String("](") + ifmt.name(); |
| QString title = ifmt.stringProperty(QTextFormat::ImageTitle); |
| if (!title.isEmpty()) |
| s += Space + DoubleQuote + title + DoubleQuote; |
| s += QLatin1Char(')'); |
| if (wrap && col + s.length() > ColumnLimit) { |
| m_stream << Newline << wrapIndentString; |
| col = m_wrappedLineIndent; |
| } |
| m_stream << s; |
| col += s.length(); |
| } else if (fmt.hasProperty(QTextFormat::AnchorHref)) { |
| QString s = QLatin1Char('[') + fragmentText + QLatin1String("](") + |
| fmt.property(QTextFormat::AnchorHref).toString() + QLatin1Char(')'); |
| if (wrap && col + s.length() > ColumnLimit) { |
| m_stream << Newline << wrapIndentString; |
| col = m_wrappedLineIndent; |
| } |
| m_stream << s; |
| col += s.length(); |
| } else { |
| QFontInfo fontInfo(fmt.font()); |
| bool monoFrag = fontInfo.fixedPitch(); |
| QString markers; |
| if (!ignoreFormat) { |
| if (monoFrag != mono && !m_indentedCodeBlock && !m_fencedCodeBlock) { |
| if (monoFrag) |
| backticks = QString(adjacentBackticksCount(fragmentText) + 1, Backtick); |
| markers += backticks; |
| if (startsOrEndsWithBacktick) |
| markers += Space; |
| mono = monoFrag; |
| } |
| if (!blockFmt.headingLevel() && !mono) { |
| if (fontInfo.bold() != bold) { |
| markers += QLatin1String("**"); |
| bold = fontInfo.bold(); |
| } |
| if (fontInfo.italic() != italic) { |
| markers += QLatin1Char('*'); |
| italic = fontInfo.italic(); |
| } |
| if (fontInfo.strikeOut() != strikeOut) { |
| markers += QLatin1String("~~"); |
| strikeOut = fontInfo.strikeOut(); |
| } |
| if (fontInfo.underline() != underline) { |
| // Markdown doesn't support underline, but the parser will treat a single underline |
| // the same as a single asterisk, and the marked fragment will be rendered in italics. |
| // That will have to do. |
| markers += QLatin1Char('_'); |
| underline = fontInfo.underline(); |
| } |
| } |
| } |
| if (wrap && col + markers.length() * 2 + fragmentText.length() > ColumnLimit) { |
| int i = 0; |
| int fragLen = fragmentText.length(); |
| bool breakingLine = false; |
| while (i < fragLen) { |
| if (col >= ColumnLimit) { |
| m_stream << Newline << wrapIndentString; |
| col = m_wrappedLineIndent; |
| while (fragmentText[i].isSpace()) |
| ++i; |
| } |
| int j = i + ColumnLimit - col; |
| if (j < fragLen) { |
| int wi = nearestWordWrapIndex(fragmentText, j); |
| if (wi < 0) { |
| j = fragLen; |
| } else if (wi >= i) { |
| j = wi; |
| breakingLine = true; |
| } |
| } else { |
| j = fragLen; |
| breakingLine = false; |
| } |
| QString subfrag = fragmentText.mid(i, j - i); |
| if (!i) { |
| m_stream << markers; |
| col += markers.length(); |
| } |
| if (col == m_wrappedLineIndent) |
| maybeEscapeFirstChar(subfrag); |
| m_stream << subfrag; |
| if (breakingLine) { |
| m_stream << Newline << wrapIndentString; |
| col = m_wrappedLineIndent; |
| } else { |
| col += subfrag.length(); |
| } |
| i = j + 1; |
| } |
| } else { |
| m_stream << markers << fragmentText; |
| col += markers.length() + fragmentText.length(); |
| } |
| } |
| } |
| if (mono) { |
| if (startsOrEndsWithBacktick) { |
| m_stream << Space; |
| col += 1; |
| } |
| m_stream << backticks; |
| col += backticks.size(); |
| } |
| if (bold) { |
| m_stream << "**"; |
| col += 2; |
| } |
| if (italic) { |
| m_stream << "*"; |
| col += 1; |
| } |
| if (underline) { |
| m_stream << "_"; |
| col += 1; |
| } |
| if (strikeOut) { |
| m_stream << "~~"; |
| col += 2; |
| } |
| if (missedBlankCodeBlockLine) |
| m_stream << Newline; |
| return col; |
| } |
| |
| QT_END_NAMESPACE |