view src/converter.cpp @ 92:7204024777b4

Add info plist template for file extension
author Andre Heinecke <andre.heinecke@intevation.de>
date Thu, 06 Oct 2016 17:05:14 +0200
parents 80ff6591101a
children ccd1dbea2536
line wrap: on
line source
/* Copyright (C) 2016 by ETH Zürich
 * Software engineering by Intevation GmbH
 *
 * This file is Free Software under the GNU GPL (v>=2)
 * and comes with ABSOLUTELY NO WARRANTY!
 * See LICENSE.txt for details.
 */

#include "converter.h"
#include <QDebug>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QTextDocument>
#include <QPrinter>
#include <QImage>
#include <QPainter>
#include <QMessageBox>
#include <QDir>
#include <QCoreApplication>
#include <QSettings>

#include "xlsxdocument.h"
#include "xlsxconditionalformatting.h"

#include "constants.h"

QTXLSX_USE_NAMESPACE

Converter::Converter(const QString &input, const QStringList &outputs,
                     const QString &title):
    QThread(Q_NULLPTR),
    mInput(input),
    mOutputs(outputs),
    mTitle(title)
{
    mTitleFmt.setFontUnderline(Format::FontUnderlineSingle);
    mTitleFmt.setFontSize(18);
    mTitleFmt.setFontName("Calibri");
    mTitleFmt.setFontBold(true);
    mTitleFmt.setVerticalAlignment(Format::AlignTop);


    mQuestionFmt.setFontSize(11);
    mQuestionFmt.setFontName("Calibri");
    mQuestionFmt.setFontBold(true);
    mQuestionFmt.setTopBorderStyle(Format::BorderThin);
    mQuestionFmt.setBottomBorderStyle(Format::BorderThin);
    mQuestionFmt.setTextWarp(true);

    mAnswerChoiceFmt.setFontSize(11);
    mAnswerChoiceFmt.setFontName("Calibri");
    mAnswerChoiceFmt.setHorizontalAlignment(Format::AlignLeft);
    mAnswerChoiceFmt.setTextWarp(true);

    mChoiceTextFmt = mAnswerChoiceFmt;
    mChoiceTextFmt.setVerticalAlignment(Format::AlignVCenter);
    mChoiceTextFmt.setTextWarp(true);

    mChoiceVotesFmt = mChoiceTextFmt;
    mChoiceVotesFmt.setFontSize(10);

    mFreeTextFmt = mQuestionFmt;
    mFreeTextFmt.setFontBold(false);

    mAnswerTextFmt = mQuestionFmt;
    mAnswerTextFmt.setVerticalAlignment(Format::AlignVCenter);
    mAnswerTextFmt.setHorizontalAlignment(Format::AlignLeft);

    mTitleStyle = QStringLiteral("<tr><td colspan='3' style='vertical-align: top;"
                                 "font-weight: bold; text-decoration:underline; font-size: 18pt;'>"
                                 "%1</td></tr><tr/>");
    mQuestionStyle = QStringLiteral("<tr><td colspan='3' style='font-size: 11pt;font-weight: bold;'"
                                    "><hr/>%1<hr/></td></tr>");
    mAnswerChoiceStyle= QStringLiteral("<tr><td colspan='3' style='text-align: left; font-size: 11pt;'>Answer</td></tr>");
    mChoiceTextStyle= QStringLiteral("<tr><td align='right' style='vertical-align: middle; font-size:11pt;'>%1</td>");
    mChoiceVotesStyle = QStringLiteral("<td align='left' style='vertical-align: middle; font-size:10pt;'><pre>%1</pre></td></tr>");
    mAnswerTextStyle = QStringLiteral("<tr><td colspan='3' style='font-weight: bold;vertical-algin: middle;"
                                      "font-size:11pt;'>Answer<hr/></td></tr>");
    mFreeTextStyle = QStringLiteral("<tr><td colspan='3'; style='font-size:11pt;'>%1<hr/></td></tr>");
    mEmptyRow = QStringLiteral("<tr style='height: %1px'/>").arg(CHOICE_ROW_HEIGHT);
}

void Converter::run()
{
    QFile infile;

    if (mInput.isEmpty()) {
        if (!infile.open(stdin, QIODevice::ReadOnly)) {
            mErrors << tr("Failed to open standard input and no input file provided.");
            return;
        }
    } else {
        infile.setFileName(mInput);
        if (!infile.open(QIODevice::ReadOnly)) {
            mErrors << tr("Failed to open %1 for reading.").arg(mInput);
            return;
        }
    }
    QTextStream instream(&infile);

    QList<QFile*> outfiles;

    if (mOutputs.isEmpty()) {
        QFile *outfile = new QFile();
        if (!outfile->open(stdout, QIODevice::WriteOnly)) {
            mErrors << tr("Failed to open standard output and no output file provided.");
            return;
        }
        outfiles << outfile;
    }
    foreach (const QString &fileName, mOutputs) {
        QFile *outfile = new QFile();
        outfile->setFileName(fileName);
        if (!outfile->open(QIODevice::WriteOnly)) {
            mErrors << tr("Failed to open %1 for writing.").arg(fileName);
            return;
        }
        outfiles << outfile;
    }
    convertToXSLX(instream, outfiles);
}

static void makeBar(QTextStream &html, double percent, QTextDocument &doc)
{
    QImage image(QSize(IMAGE_WIDTH, 25), QImage::Format_RGB32);
    QPainter painter(&image);
    QRect rect = image.rect();
    if (percent) {
        rect.setRight(rect.right() / (100. / percent));
        painter.fillRect(rect, QColor(BAR_COLOR));
        rect.setLeft(rect.right());
    }
    rect.setRight(IMAGE_WIDTH);
    painter.fillRect(rect, Qt::white);
    doc.addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("internal://bar%1.png").arg((int)percent)),
                    QVariant(image));
    html << QStringLiteral("<td style='vertical-align: middle'><img src=\"internal://bar%1.png\"/></td>").arg((int)percent);
    return;
}

static const QMap<QRegularExpression*, QString> loadExpressionsFromFile(const QString &path, QStringList &errors)
{
    QFile file(path);
    QMap<QRegularExpression*, QString> ret;

    if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
        errors << QObject::tr("Failed to open replacement file:") + "\n\"" + path + "\"";
        return ret;
    }

    while (!file.atEnd()) {
        const auto line = file.readLine();
        auto strline = QString::fromUtf8(line).trimmed();
        if (strline.startsWith(QStringLiteral(";")) || strline.isEmpty()) {
            continue;
        }

        auto split = strline.split("=");
        if (split.count() != 2) {
            errors << QObject::tr("Invalid replacement line:") + "\n\"" + strline + "\"";
            continue;
        }

        auto exp = new QRegularExpression (split[0], QRegularExpression::MultilineOption);
        if (!exp->isValid()) {
            errors << QObject::tr("Invalid regular expression:") + "\n\"" + split[0] + "\"" +
                                 "\n" + QObject::tr("Error: ") + exp->errorString();
            continue;
        }
        const auto rep = split[1].replace("\"", "");
        ret.insert(exp, rep);
        qDebug() << "Loaded replacement: " << *exp << " replacement:" << rep << "";
    }
    /* Special one that does not fit into the ini format well. */
    ret.insert(new QRegularExpression("^="), " =");
    return ret;
}

static const QMap<QRegularExpression*, QString> loadExpressions(QStringList &errors)
{
    QMap<QRegularExpression*, QString> regexs;
    /* Look for file next to our place */
    auto ourDir = QDir(QCoreApplication::applicationDirPath());
    const auto filename = QStringLiteral(CONFIG_FILE_NAME);
    if (ourDir.exists(filename)) {
        regexs = loadExpressionsFromFile(ourDir.filePath(filename), errors);
        return regexs;
    }

    /* Look in ../share/apps/PROJECT_NAME */
    ourDir.cd(QStringLiteral("../share/apps/" APPNAME).toLower());
    if (ourDir.exists(filename)) {
        regexs = loadExpressionsFromFile(ourDir.filePath(filename), errors);
    } else {
        qDebug() << "Failed to find regular expressions.";
    }
    return regexs;
}

static const QStringList sanitizeInput(QString &str)
{
    QStringList errors;
    str.replace("\r\n", "\n");
    str.replace("\n\r", "\n");
    const auto expressions = loadExpressions(errors);
    for (const auto regex: expressions.keys()) {
        str.replace(*regex, expressions.value(regex));
        delete regex;
    }
    return errors;
}

static void xlsEscape(QString &str)
{
    if (str.startsWith("=")) {
        str = " " + str;
    }
}

void Converter::convertToXSLX(QTextStream& instream, QList<QFile *>outputs)
{
    Document xlsx;
    QTextDocument doc;
    QString htmlString;
    QTextStream html (&htmlString);
    html.setCodec("UTF-8");

    ConditionalFormatting bars;

    bars.addDataBarRule(QColor(0xFF, 0x99, 0x33), ConditionalFormatting::VOT_Num,
                        "0", ConditionalFormatting::VOT_Num, "100", false);

    const double colWidth[] = COLUMN_WIDTHS;
    double sum = 0;

    for (int i = 1; i <= COLUMN_CNT; i++) {
        xlsx.setColumnWidth(i, colWidth[i-1]);
        sum += colWidth[i-1];
    }

    /* For the merged cell wordwrap trick. */
    xlsx.setColumnWidth(26, sum + 1);
    xlsx.setColumnHidden(26, true);

    const QString title = mTitle.isEmpty() ? DEFAULT_TITLE : mTitle;

    int row = 1;
    html << QStringLiteral("<!DOCTYPE html><html xmlns=\"http://www.w3.org/1999/xhtml\">"
            "<head><meta http-equiv=\"Content-Type\" content=\"text/html;charset=UTF-8\"/>"
            "<title>%1</title>"
            "</head><body><table border=\"0\" width=\"100%\">").arg(title.toHtmlEscaped());
    html << QStringLiteral("<tr><th width=\"%1%\"></th>").arg(HTML_COL1_PERCENT);
    html << QStringLiteral("<th width=\"%1%\"></th>").arg(HTML_COL2_PERCENT);
    html << QStringLiteral("<th width=\"%1%\"></th>").arg(HTML_COL3_PERCENT);

    // Set the title of the Questionaire
    xlsx.write(row++, 1, title, mTitleFmt);
    html << mTitleStyle.arg(title.toHtmlEscaped());
    xlsx.mergeCells("A1:C1");
    xlsx.setRowHeight(1, TITLE_ROW_HEIGHT);

    QString input = instream.readAll();

    QRegularExpression questionEx(QUESTION_PATTERN);
    QRegularExpression choiceEx(CHOICE_PATTERN);
    QRegularExpression choiceAltEx(CHOICE_UNFILLED_PATTERN);
    QRegularExpression freetxtEx(FREETXT_PATTERN);

    mErrors += sanitizeInput(input);

    QRegularExpressionMatch match = questionEx.match(input);
    bool foundSomething = false;
    int cursor = match.capturedEnd();
    while (match.hasMatch() && cursor != -1) {
        /* We've matched a question pattern. With the answer
           line */
        if (!match.lastCapturedIndex() == 2) {
            /* Should not happen without misconfiguration. */
            mErrors << "Internal parser error.";
            return;
        }
        foundSomething = true;
        /* A question is everything until the last two newlines
         * before an answer line.*/
        QString question;

        int lastBreaks = input.lastIndexOf(QStringLiteral("\n\n"), cursor);
        if (lastBreaks == -1) {
            /* First question */
            lastBreaks = 0;
        }

        question = input.mid(lastBreaks, match.capturedStart() - lastBreaks);
        qDebug() << "Found question: " << question;
        const QString answerLine = match.captured(1).trimmed();
        xlsx.write(row, 2, QString(" "), mQuestionFmt);
        xlsx.write(row, 3, QString(" "), mQuestionFmt);
        xlsEscape(question);
        xlsx.write(row++, 1, question, mQuestionFmt);
        html << mQuestionStyle.arg(question.toHtmlEscaped());

        if (answerLine == QStringLiteral(CHOICE_IDENTIFIER)) {
            xlsx.setRowHeight(row, CHOICE_ROW_HEIGHT);
            xlsx.write(row++, 1, "Answer", mAnswerChoiceFmt);
            html << mAnswerChoiceStyle;
            int firstChoiceRow = row;
            int lastChoiceRow = row;
repeat:
            QRegularExpressionMatch choiceMatch = choiceEx.match(input, cursor);
            while (choiceMatch.hasMatch() && choiceMatch.capturedStart() == cursor + 1) {
                /* We use the cursor here to keep track of the state. Only if an answer
                   follows immediately behind the last answer we treat it as valid as
                   otherwise we can't figure out when the next question begins. */
                cursor = choiceMatch.capturedEnd();

                /* Write the values */
                QString choiceName = choiceMatch.captured(1).trimmed();
                xlsEscape(choiceName);
                xlsx.write(row, 1, choiceName, mChoiceTextFmt);
                html << mChoiceTextStyle.arg(choiceName.toHtmlEscaped());
                qDebug() << "Captured for choice: " << choiceMatch.captured(0);
                bool ok;
                QString percentStr = choiceMatch.captured("percent");
                double percent;
                if (percentStr.isEmpty()) {
                    percent = 0;
                    ok = true;
                } else {
                    percent = percentStr.toDouble(&ok);
                }
                if (!ok) {
                    mErrors << "Unparsable number in string: " + choiceMatch.captured();
                    percent = 0;
                    percentStr = QString();
                    /* PercentStr was not a number. */
                }
                makeBar(html, percent, doc);
                xlsx.write(row, 2, percent == 0 ? QVariant() : percent);
                const QString numStr = choiceMatch.captured("num");
                const QString numVotesStringHtml = QString("%1% | %2 Number of votes").
                           arg(percentStr.isEmpty() ? QStringLiteral("0") : percentStr, 8).
                           arg(numStr.isEmpty() ? QStringLiteral("0") : numStr, 3);
                const QString numVotesStringXls = QString("%1% | %2 Number of votes").
                           arg(percentStr.isEmpty() ? QStringLiteral("0") : percentStr).
                           arg(numStr.isEmpty() ? QStringLiteral("0") : numStr);
                html << mChoiceVotesStyle.arg(numVotesStringHtml);
                xlsx.write(row, 3, numVotesStringXls, mChoiceVotesFmt);
                xlsx.setRowHeight(row, CHOICE_ROW_HEIGHT);
                /* As long as we can match a choice which is either before the next question
                   or before the end of the document */
                choiceMatch = choiceEx.match(input, cursor);
                row++;
                lastChoiceRow++;
            }
            choiceMatch = choiceAltEx.match(input, cursor);
            bool additionalFound = false;
            while (choiceMatch.hasMatch() && choiceMatch.capturedStart() <= cursor + 1) {
                additionalFound = true;
                QString choice = choiceMatch.captured(1);
                cursor = choiceMatch.capturedEnd();
                /* Alternative answer that is just a list of strings */
                qDebug() << "Captured unfilled choice: " << choice;
                html << mChoiceTextStyle.arg(choice.toHtmlEscaped());
                makeBar(html, 0, doc);
                xlsEscape(choice);
                xlsx.write(row, 1, choice);
                xlsx.write(row, 2, QVariant());
                const QString numVotesString = QStringLiteral("Keine eingegangenen Antworten");
                html << mChoiceVotesStyle.arg(numVotesString.toHtmlEscaped());
                xlsx.write(row, 3, numVotesString, mChoiceVotesFmt);
                xlsx.setRowHeight(row, CHOICE_ROW_HEIGHT);
                row++;
                lastChoiceRow++;
                choiceMatch = choiceAltEx.match(input, cursor);
                QRegularExpressionMatch realMatch = choiceEx.match(input, cursor);
                if (choiceMatch.hasMatch() && choiceMatch.capturedStart() == realMatch.capturedStart()) {
                    /* We have a real match so back to the other pattern. */
                    break;
                }
            }
            if (additionalFound) {
                goto repeat;
            }
            bars.addRange(QString("B%1:B%2").arg(firstChoiceRow).arg(lastChoiceRow));
//            xlsx.groupRows(firstChoiceRow - 2, lastChoiceRow - 1, false);
        } else if (answerLine == QStringLiteral(TEXT_IDENTIFIER)) {
            QRegularExpressionMatch textMatch = freetxtEx.match(input, cursor);
            xlsx.setRowHeight(row, CHOICE_ROW_HEIGHT);
            xlsx.write(row++, 1, "Answer", mAnswerTextFmt);
            html << mAnswerTextStyle;

            /* To handle the workaround for quotes in answers we store
             * the number of rows and only afterwards create the html rows. */
            int firstFreeRow = row;
            while (textMatch.hasMatch()) {
                if (textMatch.capturedStart() != cursor + 1) {
                    /* The format allows unescaped quotes in the text.
                       This makes a workaround neccessary. If we have
                       an Unquoted string between the next quoted string
                       and that Unquoted string is before the next question
                       we append the unquoted string and the next quoted string
                       with Quotes in the Row.*/
                    QRegularExpressionMatch nextQuestion = questionEx.match(input, cursor);
                    if (nextQuestion.hasMatch() &&
                        nextQuestion.capturedStart() < textMatch.capturedEnd()) {
                        /* The next question comes before the textMatch so we really have
                           a new question. */
                        break;
                    }
                    const QString lastRow = xlsx.read(row - 1, 26).toString();
                    int unquotedLen = textMatch.capturedStart() - cursor;
                    const QString unquoted = input.mid(cursor, unquotedLen);
                    if (unquoted.startsWith("\n\n")) {
                        qDebug() << "Double newline outside quote. Assuming a question follows.";
                        break;
                    }
                    qDebug() << "Found inner quoted string: " << unquoted;

                    /* Now combine */
                    QString combined = QString("%1\"%2\"%3").arg(lastRow).
                                                                 arg(unquoted).
                                                                 arg(textMatch.captured(1).trimmed());
                    qDebug() << "Last row: " << lastRow;
                    qDebug() << "Next Question is at: " << nextQuestion.capturedStart();
                    qDebug() << "Text match is: " << textMatch.captured(1).trimmed();
                    qDebug() << "cursor is at: " << cursor;
                    qDebug() << "text match starts at: " << textMatch.capturedStart();
                    xlsEscape(combined);
                    xlsx.write(row - 1, 26, combined, mFreeTextFmt);
                    xlsx.write(row - 1, 1, combined, mFreeTextFmt);
                    cursor = textMatch.capturedEnd();
                    textMatch = freetxtEx.match(input, cursor);
                    continue;
                }
                cursor = textMatch.capturedEnd();

                QString text = textMatch.captured(1).trimmed();
                qDebug() << "Found free text: " << text;
                if (text.startsWith("=")) {
                    text = " " + text;
                }

                /* Merge the cells */
                xlsx.mergeCells(QString("A%1:C%1").arg(row), mFreeTextFmt);

                /* Merged cells ignore wordwrap the following trick is based on:
                   http://excel.tips.net/T003207_Automatic_Row_Height_For_Merged_Cells_with_Text_Wrap.html
                */
                /* Write the values */
                xlsEscape(text);
                xlsx.write(QString("Z%1").arg(row), text, mFreeTextFmt);
                xlsx.write(row, 1, text, mFreeTextFmt);
                row++;

                textMatch = freetxtEx.match(input, cursor);
            }
            for (int i = firstFreeRow; i < row; i++) {
                html << mFreeTextStyle.arg(xlsx.read(i, 1).toString().toHtmlEscaped());
            }
        }
        /* Insert Empty row. */
        xlsx.setRowHeight(row++, CHOICE_ROW_HEIGHT);
        match = questionEx.match(input, cursor);
        cursor = match.capturedEnd();
        html << mEmptyRow;
    }
    xlsx.addConditionalFormatting(bars);

    if (!foundSomething) {
        mErrors << tr("Failed to parse input document.");
    }

    html << "</table></body></html>";
    doc.setHtml(htmlString);

    /* Fixup images for html */
    QRegularExpression htmlRe = QRegularExpression("<td style='vertical-align: middle'><img src=\"internal://bar(\\d+).png\"/></td>");
    htmlString.replace(htmlRe, QStringLiteral("<td style='background:linear-gradient(to right,"
                                              BAR_COLOR ", " BAR_COLOR " \\1%, #ffffff \\1%)'></td>"));

    foreach (QFile *output, outputs) {
        const QString fName = output->fileName().toLower();
        if (fName.endsWith(".html")) {
            QTextStream outstream(output);
            outstream.setCodec("UTF-8");
            outstream << htmlString;
            output->close();
        } else if (fName.endsWith(".pdf")) {
            output->close();
            QPrinter printer(QPrinter::PrinterResolution);
            printer.setOutputFormat(QPrinter::PdfFormat);
            printer.setPaperSize(QPrinter::A4);
            printer.setOutputFileName(output->fileName());
            /*
            QPageLayout layout = printer.pageLayout();
            layout.setUnits(QPageLayout::Millimeter);
            layout.setMargins(QMarginsF(20, 20, 20, 20));
            printer.setPageLayout(layout);
            doc.setPageSize(printer.pageRect().size());
            */
            doc.print(&printer);
        } else {
            if (!xlsx.saveAs(output)) {
                mErrors << tr("Saving the XLSX document failed.");
            }
            output->close();
        }
    }
}
This site is hosted by Intevation GmbH (Datenschutzerklärung und Impressum | Privacy Policy and Imprint)