/* * Oak Video Editor - Non-Linear Video Editor * Copyright (C) 2025 Olive CE Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "htmlapp.h" #include #include #include #include #include #include #include #include #include #include #include "common/xmlutils.h" #include #include #include "common/xmlutils.h" namespace olive { const QVector Html::k_block_tags = { QStringLiteral("p"), QStringLiteral("div") }; inline bool str_equals(const QStringView &a, const QStringView &b) { return !a.compare(b, Qt::CaseInsensitive); } QString Html::doc_to_html(const QTextDocument *doc) { QString html; QXmlStreamWriter writer(&html); //writer.setAutoFormatting(true); for (auto it = doc->begin(); it != doc->end(); it = it.next()) { write_block(&writer, it); } return html; } struct HtmlNode { QString tag; QTextCharFormat format; }; QTextCharFormat merge_html_formats(const QVector &stack) { QTextCharFormat f; for (int i = 0; i < stack.size(); i++) { f.merge(stack.at(i).format); } return f; } void Html::html_to_doc(QTextDocument *doc, const QString &html) { // Empty doc doc->clear(); bool inside_block = true; // Create cursor, which appears to be Qt's official way of inserting blocks and fragments QTextCursor c(doc); QString wrapped = QStringLiteral("").append(html).append(""); QXmlStreamReader reader(wrapped); QVector fmt_stack; QTextCharFormat default_fmt; default_fmt.setFontWeight(QFont::Normal); fmt_stack.append({ QStringLiteral("html"), default_fmt }); QTextCharFormat current_fmt; while (!reader.atEnd()) { reader.readNext(); if (reader.tokenType() == QXmlStreamReader::StartElement) { QString tag = reader.name().toString().toLower(); fmt_stack.append({ tag, read_char_format(reader.attributes()) }); current_fmt = merge_html_formats(fmt_stack); if (k_block_tags.contains(tag)) { QTextBlockFormat block_fmt = read_block_format(reader.attributes()); if (inside_block) { c.setBlockFormat(block_fmt); c.setBlockCharFormat(current_fmt); } else { c.insertBlock(block_fmt, current_fmt); inside_block = true; } } } else if (reader.tokenType() == QXmlStreamReader::Characters) { QString characters = reader.text().toString(); c.insertText(characters, current_fmt); } else if (reader.tokenType() == QXmlStreamReader::EndElement) { QString tag = reader.name().toString().toLower(); for (int i = fmt_stack.size() - 1; i >= 0; i--) { if (fmt_stack.at(i).tag == tag) { fmt_stack.removeAt(i); current_fmt = merge_html_formats(fmt_stack); if (k_block_tags.contains(tag)) { inside_block = false; } break; } } } } if (reader.error()) { qCritical() << "Failed to parse HTML:" << reader.errorString(); } } void Html::write_block(QXmlStreamWriter *writer, const QTextBlock &block) { writer->writeStartElement(QStringLiteral("p")); const QTextBlockFormat &fmt = block.blockFormat(); // Write block alignment if (!(fmt.alignment() & Qt::AlignLeft)) { if (fmt.alignment() & Qt::AlignRight) { writer->writeAttribute(QStringLiteral("align"), QStringLiteral("right")); } else if (fmt.alignment() & Qt::AlignHCenter) { writer->writeAttribute(QStringLiteral("align"), QStringLiteral("center")); } else if (fmt.alignment() & Qt::AlignJustify) { writer->writeAttribute(QStringLiteral("align"), QStringLiteral("justify")); } } // RTL support if (block.textDirection() == Qt::RightToLeft) { writer->writeAttribute(QStringLiteral("dir"), QStringLiteral("rtl")); } // Write CSS attributes QString style; if (fmt.lineHeightType() != QTextBlockFormat::SingleHeight) { write_css_property(&style, QStringLiteral("line-height"), QStringLiteral("%1%").arg(fmt.lineHeight())); } write_char_format(&style, block.charFormat()); if (!style.isEmpty()) { writer->writeAttribute(QStringLiteral("style"), style); } auto it = block.begin(); if (it != block.end()) { for (; it != block.end(); it++) { write_fragment(writer, it.fragment()); } } writer->writeEndElement(); // p } void Html::write_fragment(QXmlStreamWriter *writer, const QTextFragment &fragment) { const QTextCharFormat &fmt = fragment.charFormat(); writer->writeStartElement(QStringLiteral("span")); // Write CSS attributes QString style; write_char_format(&style, fmt); if (!style.isEmpty()) { writer->writeAttribute(QStringLiteral("style"), style); } QStringList lines = fragment.text().split(QChar::LineSeparator); bool first_line = true; foreach (const QString &l, lines) { if (first_line) { first_line = false; } else { writer->writeEmptyElement(QStringLiteral("br")); } writer->writeCharacters(l); } writer->writeEndElement(); // span } void Html::write_css_property(QString *style, const QString &key, const QStringList &values) { QString value; foreach (QString v, values) { if (v.contains(' ')) { v = QStringLiteral("'%1'").arg(v); } append_string_auto_space(&value, v); } append_string_auto_space(style, QStringLiteral("%1: %2;").arg(key, value)); } void Html::write_char_format(QString *style, const QTextCharFormat &fmt) { QStringList families = fmt.fontFamilies().toStringList(); if (!families.isEmpty()) { write_css_property(style, QStringLiteral("font-family"), families.first()); } if (fmt.hasProperty(QTextFormat::FontPointSize)) { write_css_property( style, QStringLiteral("font-size"), QStringLiteral("%1pt").arg(QString::number(fmt.fontPointSize()))); } if (fmt.hasProperty(QTextFormat::FontWeight)) { write_css_property(style, QStringLiteral("font-weight"), QString::number(fmt.fontWeight() * 8)); } if (fmt.hasProperty(QTextFormat::FontItalic)) { write_css_property(style, QStringLiteral("font-style"), fmt.fontItalic() ? QStringLiteral("italic") : QStringLiteral("normal")); } if (fmt.hasProperty(QTextFormat::FontStyleName)) { write_css_property(style, QStringLiteral("-ove-font-style"), fmt.fontStyleName().toString()); } QStringList deco; if (fmt.fontUnderline()) { deco.append(QStringLiteral("underline")); } if (fmt.fontStrikeOut()) { deco.append(QStringLiteral("line-through")); } if (fmt.fontOverline()) { deco.append(QStringLiteral("overline")); } if (!deco.isEmpty()) { write_css_property(style, QStringLiteral("text-decoration"), deco); } if (fmt.foreground().style() != Qt::NoBrush) { const QColor &color = fmt.foreground().color(); QString cs; if (color.alpha() == 255) { cs = color.name(); } else if (color.alpha()) { cs = QStringLiteral("rgba(%1, %2, %3, %4)") .arg(QString::number(color.red()), QString::number(color.green()), QString::number(color.blue()), QString::number(color.alphaF())); } write_css_property(style, QStringLiteral("color"), cs); } if (fmt.fontCapitalization() != QFont::MixedCase) { if (fmt.fontCapitalization() == QFont::SmallCaps) { write_css_property(style, QStringLiteral("font-variant"), QStringLiteral("small-caps")); // TODO: Add others } } if (fmt.fontLetterSpacing() != 0.0) { write_css_property(style, QStringLiteral("letter-spacing"), QStringLiteral("%1%").arg( QString::number(fmt.fontLetterSpacing()))); } if (fmt.fontStretch() != 0) { write_css_property( style, QStringLiteral("font-stretch"), QStringLiteral("%1%").arg(QString::number(fmt.fontStretch()))); } } QTextCharFormat Html::read_char_format(const QXmlStreamAttributes &attributes) { QTextCharFormat fmt; foreach (const QXmlStreamAttribute &attr, attributes) { if (str_equals(attr.name(), QStringLiteral("style"))) { auto css = get_css_from_style(attr.value().toString()); for (auto it = css.begin(); it != css.end(); it++) { const QString &first_val = it.value().first(); if (it.key() == QStringLiteral("font-family")) { fmt.setFontFamilies({ first_val }); } else if (it.key() == QStringLiteral("font-size")) { if (first_val.endsWith(QStringLiteral("pt"), Qt::CaseInsensitive)) { fmt.setFontPointSize(first_val.chopped(2).toDouble()); } } else if (it.key() == QStringLiteral("font-weight")) { fmt.setFontWeight(first_val.toInt() / 8); } else if (it.key() == QStringLiteral("font-style")) { fmt.setFontItalic( str_equals(first_val, QStringLiteral("italic"))); } else if (it.key() == QStringLiteral("text-decoration")) { foreach (const QString &v, it.value()) { if (str_equals(v, QStringLiteral("underline"))) { fmt.setFontUnderline(true); } else if (str_equals(v, QStringLiteral("line-through"))) { fmt.setFontStrikeOut(true); } else if (str_equals(v, QStringLiteral("overline"))) { fmt.setFontOverline(true); } } } else if (it.key() == QStringLiteral("color")) { if (first_val.startsWith(QStringLiteral("rgba"), Qt::CaseInsensitive)) { QString vals_only = first_val; vals_only.remove(QStringLiteral("rgba")); vals_only.remove(QStringLiteral("(")); vals_only.remove(QStringLiteral(")")); QStringList rgba = vals_only.split(','); if (rgba.size() == 4) { QColor c; c.setRed(rgba.at(0).toInt()); // Writer emits 0-255 RGB (CSS rgba() convention) c.setGreen(rgba.at(1).toInt()); c.setBlue(rgba.at(2).toInt()); c.setAlphaF(rgba.at(3).toDouble()); fmt.setForeground(c); } } else { fmt.setForeground(QColor(first_val)); } } else if (it.key() == QStringLiteral("font-variant")) { if (str_equals(first_val, QStringLiteral("small-caps"))) { fmt.setFontCapitalization(QFont::SmallCaps); } } else if (it.key() == QStringLiteral("letter-spacing")) { if (first_val.contains(QChar('%'))) { fmt.setFontLetterSpacing( first_val.chopped(1).toDouble()); } } else if (it.key() == QStringLiteral("font-stretch")) { if (first_val.contains(QChar('%'))) { fmt.setFontStretch(first_val.chopped(1).toInt()); } } else if (it.key() == QStringLiteral("-ove-font-style")) { fmt.setFontStyleName(first_val); } } } } return fmt; } QTextBlockFormat Html::read_block_format(const QXmlStreamAttributes &attributes) { QTextBlockFormat block_fmt; foreach (const QXmlStreamAttribute &attr, attributes) { if (str_equals(attr.name(), QStringLiteral("align"))) { if (str_equals(attr.value(), QStringLiteral("right"))) { block_fmt.setAlignment(Qt::AlignRight); } else if (str_equals(attr.value(), QStringLiteral("center"))) { block_fmt.setAlignment(Qt::AlignHCenter); } else if (str_equals(attr.value(), QStringLiteral("justify"))) { block_fmt.setAlignment(Qt::AlignJustify); } } else if (str_equals(attr.name(), QStringLiteral("dir"))) { if (str_equals(attr.value(), QStringLiteral("rtl"))) { block_fmt.setLayoutDirection(Qt::RightToLeft); } } else if (str_equals(attr.name(), QStringLiteral("style"))) { auto css = get_css_from_style(attr.value().toString()); for (auto it = css.begin(); it != css.end(); it++) { if (it.key() == QStringLiteral("line-height")) { const QString &first_val = it.value().constFirst(); if (first_val.contains(QChar('%'))) { block_fmt.setLineHeight( first_val.chopped(1).toDouble(), QTextBlockFormat::ProportionalHeight); } } } } } return block_fmt; } void Html::append_string_auto_space(QString *s, const QString &append) { if (!s->isEmpty()) { s->append(QChar(' ')); } s->append(append); } QMap Html::get_css_from_style(const QString &s) { QMap map; QStringList list = s.split(QChar(';')); foreach (const QString &a, list) { QStringList kv = a.split(QChar(':')); if (kv.size() != 2) { continue; } // I'm sure there's regex that could do this, but I couldn't figure it out. It needs to split // by space EXCEPT within quotes OR double-quotes, and said quotes should be EXCLUDED from each // match. Also commas should be filtered out. QStringList values; const QString &val = kv.at(1); QChar in_quote(0); QString current_str; for (int i = 0; i < val.size(); i++) { const QChar ¤t_char = val.at(i); if (!in_quote.isNull()) { // If inside quotes and character isn't quote, indiscriminately append char if (current_char == in_quote) { in_quote = QChar(0); } else { current_str.append(current_char); } } else if (current_char.isSpace() || current_char == QChar(',')) { // Dump current if (!current_str.isEmpty()) { values.append(current_str); current_str.clear(); } } else if (in_quote.isNull() && (current_char == QChar('\'') || current_char == QChar('"'))) { in_quote = current_char; } else { current_str.append(current_char); } } if (!current_str.isEmpty()) { values.append(current_str); } // Not sure if this will ever happen, but just in case, we will avoid assert failures with this if (values.isEmpty()) { values.append(QString()); } map[kv.at(0).trimmed().toLower()] = values; } return map; } }