311 lines
12 KiB
C++
311 lines
12 KiB
C++
#include "Document/EditorSession.h"
|
|
#include "Document/PixelAdjust.h"
|
|
#include "IO/ImageExporter.h"
|
|
#include "Logging.h"
|
|
#include "Rendering/TextLayout.h"
|
|
#include <QPainter>
|
|
#include <QTextBoundaryFinder>
|
|
#include <cmath>
|
|
#include <stdexcept>
|
|
|
|
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> 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> LayerText::loaded(const std::optional<LayerTextStyle> &style, const std::optional<ImportedImage> &image)
|
|
{
|
|
if (!style || !style->isValid() || !image)
|
|
return std::nullopt;
|
|
return LayerText{*style, image->identity()};
|
|
}
|
|
|
|
std::optional<LayerText> ImageLayer::liveText() const
|
|
{
|
|
if (!text || !asset || asset->identity() != text->image)
|
|
return std::nullopt;
|
|
return text;
|
|
}
|
|
|
|
void EditorSession::setTextDraft(std::optional<TextDraft> 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<ImageLayer> target;
|
|
const QSet<QUuid> 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<ImageLayer> 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<TextDraft> 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<ImageLayer> active = activeLayer();
|
|
return active && active->liveText() ? active->liveText()->style : m_textDefaults;
|
|
}
|
|
|
|
void EditorSession::changeTextStyle(const std::function<void(LayerTextStyle &)> &change)
|
|
{
|
|
const std::optional<ImageLayer> 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;
|
|
}
|