#include "Document/EditorSession.h" #include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" #include "Logging.h" #include "Rendering/TextLayout.h" #include #include #include #include QString rawValue(TextAlignment alignment) { switch (alignment) { case TextAlignment::left: return QStringLiteral("Left"); case TextAlignment::center: return QStringLiteral("Center"); case TextAlignment::right: return QStringLiteral("Right"); } throw std::logic_error("unknown text alignment"); } std::optional textAlignment(const QString &text) { for (const TextAlignment alignment : allTextAlignments) { if (rawValue(alignment) == text) return alignment; } return std::nullopt; } bool LayerTextStyle::boxIsValid() const { if (!boxSize) return true; const double width = boxSize->width(), height = boxSize->height(); return width >= 16 && width <= 30'000 && height >= 16 && height <= 30'000 && width * height <= 100'000'000; } bool LayerTextStyle::isValid() const { // Ranges refuse NaN and infinities: Swift's isFinite adds nothing. const auto within = [](double value, double low, double high) { return value >= low && value <= high; }; return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1) && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000); } std::optional LayerText::loaded(const std::optional &style, const std::optional &image) { if (!style || !style->isValid() || !image) return std::nullopt; return LayerText{*style, image->identity()}; } std::optional ImageLayer::liveText() const { if (!text || !asset || asset->identity() != text->image) return std::nullopt; return text; } void EditorSession::setTextDraft(std::optional draft) { // Only a draft that ends frees waiting file requests. const bool ended = m_textDraft && !draft; m_textDraft = std::move(draft); if (ended) resumeFileRequests(); notify(); } void EditorSession::beginText(QPointF point, bool newLayer) { if (!canEditLayers() || !std::isfinite(point.x()) || !std::isfinite(point.y())) return; // The topmost visible text under the point, if wanted. std::optional target; const QSet visible = m_document->effectiveVisibleIDs(); for (auto layer = m_document->layers.rbegin(); !newLayer && !target && layer != m_document->layers.rend(); ++layer) { if (visible.contains(layer->id) && layer->liveText() && layer->transform.contains(point)) target = *layer; } if (target) selectLayer(target->id); LayerTextStyle style = target ? target->liveText()->style : m_textDefaults; if (!target) { style.content = QString(); // New text takes the foreground colour, as painting does. if (!m_isMaskSelected) { style.red = foregroundColor().red; style.green = foregroundColor().green; style.blue = foregroundColor().blue; } // A click makes point text: no box of its own. style.boxSize = std::nullopt; } m_tool = NavigationTool::type; setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt, .origin = target ? target->origin() : point, .transform = target ? std::optional(target->transform) : std::nullopt, .style = style}); } void EditorSession::editActiveText() { const std::optional layer = activeLayer(); if (!canEditLayers() || !layer || !layer->liveText()) return; m_tool = NavigationTool::type; setTextDraft(TextDraft{.documentID = m_document->id, .layerID = layer->id, .origin = layer->origin(), .transform = layer->transform, .style = layer->liveText()->style}); } bool EditorSession::applyText(TextDraft draft) { if (!m_document || m_document->id != draft.documentID || !draft.style.isValid()) return false; // Cleared first, as the gate reads it; restored if refused. const std::optional pending = std::exchange(m_textDraft, std::nullopt); const auto refuse = [&] { m_textDraft = pending; return false; }; if (!canEditLayers()) return refuse(); const auto succeed = [&] { resumeFileRequests(); notify(); return true; }; if (!draft.layerID && draft.style.content.trimmed().isEmpty()) return succeed(); try { const QImage image = textImage(draft.style); if (draft.layerID) { const int index = indexOf(m_document->layers, *draft.layerID); if (index < 0 || !m_document->layers[size_t(index)].liveText()) return refuse(); const ImageLayer layer = m_document->layers[size_t(index)]; if (layer.liveText()->style == draft.style && (!draft.transform || *draft.transform == layer.transform)) return succeed(); const QImage thumbnail = PixelAdjust::thumbnail(image); LayerTransform transform = draft.transform.value_or(layer.transform); // The corner stays; so do the scale, turn and flips. const QPointF anchor = transform.point(QPointF(0, 0)); if (!draft.transform || !draft.style.boxSize) { transform.size = QSizeF(image.width() * transform.size.width() / layer.asset->size().width(), image.height() * transform.size.height() / layer.asset->size().height()); transform.origin += anchor - transform.point(QPointF(0, 0)); } if (!transform.isValid()) throw ProjectError(ProjectError::Kind::tooLarge); beginEdit(QStringLiteral("Edit Text")); ImageLayer &edited = m_document->layers[size_t(index)]; if (edited.mask && !edited.mask->placement) edited.mask->placement = layer.maskTransform(); edited.asset = ImportedImage(image, thumbnail, layer.asset->name); edited.text = LayerText{draft.style, edited.asset->identity()}; edited.transform = transform; endEdit(); } else { addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style); } m_textDefaults = draft.style; requestCanvasFocus(); return succeed(); } catch (const ProjectError &error) { // The draft is back before the error is announced. refuse(); setBrushError(QString::fromUtf8(error.what())); } catch (const ExportError &error) { refuse(); setBrushError(QString::fromUtf8(error.what())); } return false; } bool EditorSession::finishText() { if (!m_textDraft) return true; return applyText(*m_textDraft); } void EditorSession::cancelText() { setTextDraft(std::nullopt); requestCanvasFocus(); } void EditorSession::beginText(const QRectF &rect) { if (!canEditLayers() || !std::isfinite(rect.width()) || !std::isfinite(rect.height())) return; LayerTextStyle style = m_textDefaults; style.boxSize = QSizeF(std::max(16.0, std::round(rect.width())), std::max(16.0, std::round(rect.height()))); if (!style.boxIsValid()) { setBrushError(QStringLiteral("That text box exceeds the 30,000-pixel or 100-megapixel limit.")); return; } beginText(rect.topLeft(), true); if (m_textDraft) { TextDraft draft = *m_textDraft; draft.style.boxSize = style.boxSize; setTextDraft(draft); } } bool EditorSession::recolorText(QUuid id, const PaletteColor &color) { if (!canEditLayers()) return false; const int index = indexOf(m_document->layers, id); if (index < 0 || !m_document->layers[size_t(index)].liveText()) return false; const ImageLayer layer = m_document->layers[size_t(index)]; LayerTextStyle style = layer.liveText()->style; if (style.red == color.red && style.green == color.green && style.blue == color.blue) return true; style.red = color.red; style.green = color.green; style.blue = color.blue; QImage image, thumbnail; try { image = textImage(style); thumbnail = PixelAdjust::thumbnail(image); } catch (const std::runtime_error &error) { // An invalid colour too; Swift's try? fills without a word. qCWarning(lcApp) << "text could not be repainted:" << error.what(); return false; } finishOpacityEdit(); beginEdit(QStringLiteral("Fill Text")); ImageLayer &edited = m_document->layers[size_t(index)]; edited.asset = ImportedImage(image, thumbnail, layer.asset->name); edited.text = LayerText{style, edited.asset->identity()}; endEdit(); return true; } LayerTextStyle EditorSession::currentTextStyle() const { if (m_textDraft) return m_textDraft->style; const std::optional active = activeLayer(); return active && active->liveText() ? active->liveText()->style : m_textDefaults; } void EditorSession::changeTextStyle(const std::function &change) { const std::optional active = activeLayer(); if (!m_textDraft && active && active->liveText()) editActiveText(); if (m_textDraft) { TextDraft draft = *m_textDraft; change(draft.style); if (draft.style.isValid()) setTextDraft(draft); return; } LayerTextStyle style = m_textDefaults; change(style); if (!style.isValid()) return; m_textDefaults = style; notify(); } QString EditorSession::layerName(const QString &content) { // Whitespace runs become single spaces: the row stays one line. const QString flattened = content.simplified(); if (flattened.isEmpty()) return QStringLiteral("Text"); QTextBoundaryFinder characters(QTextBoundaryFinder::Grapheme, flattened); for (int count = 0; count < 40 && characters.toNextBoundary() >= 0;) ++count; // Past the end the position is -1: `left` keeps everything. return flattened.left(characters.position()); } QSizeF EditorSession::textBoxSize(const LayerTextStyle &style) { if (style.boxSize) return *style.boxSize; const TextLines lines(style, QSizeF(100'000, 100'000)); const QSizeF measured = lines.usedSize(); const double padding = LayerTextStyle::padding; // A caret's width more; an empty paragraph lays a line. return QSizeF(std::ceil(measured.width() + padding * 2 + style.fontSize * 0.1), std::ceil(measured.height() + padding * 2)); } QImage EditorSession::textImage(const LayerTextStyle &style) { if (!style.isValid()) throw ProjectError(ProjectError::Kind::invalid); const QSizeF size = textBoxSize(style); const double width = std::ceil(size.width()), height = std::ceil(size.height()); // A valid style measures sixteen pixels at least. if (!(width <= 30'000 && height <= 30'000 && width * height <= 100'000'000)) throw ProjectError(ProjectError::Kind::tooLarge); QImage context = BrushRaster::context(int(width), int(height), false); const double padding = LayerTextStyle::padding; const TextLines lines(style, QSizeF(std::max(1.0, width - 2 * padding), std::max(1.0, height - 2 * padding))); QPainter painter(&context); painter.setPen(QColor::fromRgbF(float(style.red), float(style.green), float(style.blue))); lines.draw(painter, QPointF(padding, padding)); painter.end(); return context; }